Merge remote-tracking branch 'upstream/master'

This commit is contained in:
James Wykeham-Martin 2018-02-02 09:09:43 +00:00
commit 32296d91b7
93 changed files with 23687 additions and 56485 deletions

4
.gitignore vendored
View File

@ -1,4 +1,4 @@
/target/
target/
**/*.rs.bk
Cargo.lock
.vscode
@ -8,6 +8,6 @@ _build
vendor/
.flatpak-builder/
flatpak-build/
flatpak-repo/
repo/
Makefile
.criterion

View File

@ -11,29 +11,30 @@ before_script:
- apt-get install -yqq --no-install-recommends libgtk-3-dev
# - apt-get install -yqq --no-install-recommends meson
# kcov
# - apt-get install -y libcurl4-openssl-dev libelf-dev libdw-dev cmake gcc binutils-dev libiberty-dev
.cargo_test_template: &cargo_test
stage: test
script:
- rustc --version && cargo --version
# Force regeneration of gresources regardless of artifacts chage
- cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
- cargo build
- cargo test --verbose -- --test-threads=1
- cargo test --verbose -- --test-threads=1 --ignored
variables:
# RUSTFLAGS: "-C link-dead-code"
RUST_BACKTRACE: "FULL"
CARGO_HOME: $CI_PROJECT_DIR/cargo
stable:test:
# https://hub.docker.com/_/rust/
image: "rust"
<<: *cargo_test
nightly:test:
# https://hub.docker.com/r/rustlang/rust/
image: "rustlang/rust:nightly"
<<: *cargo_test
# nightly:test:
# # https://hub.docker.com/r/rustlang/rust/
# image: "rustlang/rust:nightly"
# <<: *cargo_test
# Configure and run rustfmt on nightly
# Exits and builds fails if on bad format
@ -44,15 +45,17 @@ rustfmt:
CFG_RELEASE_CHANNEL: "nightly"
script:
- rustc --version && cargo --version
- cargo install rustfmt-nightly
- cargo install rustfmt-nightly --force
- cargo fmt --all -- --write-mode=diff
# Configure and run clippy on nightly
# Only fails on errors atm.
clippy:
image: "rustlang/rust:nightly"
stage: lint
script:
- rustc --version && cargo --version
- cargo install clippy
- cargo clippy --all
# clippy:
# image: "rustlang/rust:nightly"
# stage: lint
# script:
# - rustc --version && cargo --version
# - cargo install clippy --force
# # Force regeneration of gresources regardless of artifacts chage
# - cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../
# - cargo clippy --all

View File

@ -0,0 +1,24 @@
## Invalid RSS Feed Template.
Please provide the source of the xml rss feed.
**Feed URL**
example.com/podcast
**Detailed description of the issue**
Would be helpfull if error messages where included from stderr.
If you are not sure how to do it, feel free to ask and we will walk you through it!
Some common cases might be:
* Feed cannot be added
* Broken Feed Image
* Episode(s) do not download
Steps to reproduce:
1. Open Hammond
2. Do an action
3. ...

View File

@ -7,14 +7,3 @@ Steps to reproduce:
2. Do an action
3. ...
## Design Tasks
* [ ] design tasks
## Development Tasks
* [ ] development tasks
## QA Tasks
* [ ] qa (quality assurance) tasks

19
CHANGELOG.md Normal file
View File

@ -0,0 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.0] - xxxx-01-xx
No Notes where provided prior to this release.
## [0.2.0] - 2017-11-28
No Notes where provided prior to this release.
## [0.1.0] - 2017-11-13
Initial Release

View File

@ -1,30 +1,57 @@
## Contributing
## Contributing to Hammond
Thank you for looking in this file!
When contributing to the development of Hammond, please first discuss the change you wish to make via issue, email, or any other method with the maintainers before making a change.
Please note we have a code of conduct, please follow it in all your interactions with the project.
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
Please note we have a [code of conduct](https://wiki.gnome.org/Foundation/CodeOfConduc), please follow it in all your interactions with the project.
## Source repository
Hammond's main source repository is at gitlab.gnome.org. You can view
the web interface [here](https://gitlab.gnome.org/alatiera/hammond)
Development happens in the master branch.
Note that we don't do bug tracking in the Github mirror.
If you need to publish a branch, feel free to do it at any
publically-accessible Git hosting service, although gitlab.gnome.org
makes things easier for the maintainers.
## Style
We use rustfmt for code formatting and we enforce it on the gitlab-CI server.
We use [rustfmt](https://github.com/rust-lang-nursery/rustfmt) for code formatting and we enforce it on the gitlab-CI server.
Quick setup
```
cargo install rustfmt-nightly
cargo fmt --all
```
```
cargo install rustfmt-nightly
cargo fmt --all
```
It is recommended to add a pre-commit hook to run cargo test and cargo fmt
```
#!/bin/sh
cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
```
It is recommended to add a pre-commit hook to run cargo test and `cargo fmt`.
Don't forget to `git add` again after `cargo fmt`.
```
#!/bin/sh
cargo test -- --test-threads=1 && cargo fmt --all -- --write-mode=diff
```
## Running the test suite
Running the tests requires an internet connection and it it will download some files from the [Internet Archive](archive.org)
The test suite sets a temporary sqlite database in the `/tmp` folder.
Due to that it's not possible to run them in parrallel.
In order to run the test suite use the following: `cargo test -- --test-threads=1`
# Issues, issues and more issues!
There are many ways you can contribute to Hammond, and all of them involve creating issues
in [Hammond issue tracker](https://gitlab.gnome.org/alatiera/Hammond/issues). This is the
entry point for your contribution.
in [Hammond issue tracker](https://gitlab.gnome.org/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
ticket:
@ -67,8 +94,10 @@ Steps to reproduce:
## Pull Request Process
1. Ensure your code compiles. Run `make` before creating the pull request.
2. If you're adding new API, it must be properly documented.
3. The commit message is formatted as follows:
2. Ensure the test suit passes. Run `cargo test -- --test-threads=1`.
3. Ensure your code is properly formated. Run `cargo fmt --all`.
4. If you're adding new API, it must be properly documented.
5. The commit message is formatted as follows:
```
component: <summary>
@ -78,7 +107,7 @@ Steps to reproduce:
<link to the bug ticket>
```
4. You may merge the pull request in once you have the sign-off of the maintainers, or if you
6. You may merge the pull request in once you have the sign-off of the maintainers, or if you
do not have permission to do that, you may request the second reviewer to merge it for you.
## Code of Conduct

908
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,19 @@
# Hammond
## Multithreaded and reliable Gtk+ Podcast client.
This is a prototype of a podcast client 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)
![podcasts_view](./assets/podcasts_view.png)
![podcast_widget](./assets/podcast_widget.png)
### Features
## Getting in Touch
If you have any questions regarding the
use or development of Hammond, want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
* TBA
Sidenote:
There isn't much documentation yet, so you will probably have question about parts of the Code.
![episdes_view](./assets/episodes_view.png)
![shows_view](./assets/shows_view.png)
![show_widget](./assets/show_widget.png)
## Quick start
The following steps assume you have a working installation of rustc and cargo.
If you dont take a look at [rustup.rs](rustup.rs)
@ -25,7 +23,22 @@ cd Hammond/
cargo run -p hammond-gtk --release
```
## Broken Feeds
Found a feed that does not work in Hammond?
Please [open an issue](https://gitlab.gnome.org/alatiera/Hammond/issues/new) and choose the `BrokenFeed` template so we will know and fix it!
## Getting in Touch
If you have any questions regarding the use or development of Hammond,
want to discuss design or simply hang out, please join us in [#hammond on irc.gnome.org.](irc://irc.gnome.org/#hammond)
Note:
There isn't much documentation yet, so you will probably have question about parts of the Code.
## Install from soure
```sh
git clone https://gitlab.gnome.org/alatiera/hammond.git
cd Hammond/
@ -33,31 +46,43 @@ cd Hammond/
make && sudo make install
```
**Additionall:**
**Additional:**
You can run `sudo make uninstall` for removal
And `make clean` to clean up the enviroment after instalation.
### Flatpak
Flatpak instructions... Soon™.
#### Building a Flatpak
Download the `org.gnome.Hammond.json` flatpak manifest from this repo.
```bash
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo # Add flathub repo
flatpak --user install flathub org.freedesktop.Sdk.Extension.rust-stable # Install the required rust-stable extension from flathub
flatpak-builder --repo=repo hammond org.gnome.Hammond.json --force-clean
flatpak build-bundle repo hammond org.gnome.Hammond
```
## Building
### Dependancies
### Dependencies
* Rust stable 1.22 or later.
* Gtk+ 3.22 or later
* Meson
**Debian/Ubuntu**:
**Debian/Ubuntu**
```sh
apt-get update -yqq
apt-get install -yqq --no-install-recommends build-essential
apt-get install -yqq --no-install-recommends libgtk-3-dev meson
```
**Fedora**:
**Fedora**
```sh
dnf install -y gtk3-devel glib2-devel openssl-devel sqlite-devel meson
```
@ -70,14 +95,6 @@ cd Hammond/
cargo build --all
```
## Call for designers
Currently there no design plans or mockups. They are highly needed in order to advance the Gtk Client.
There is the will for a complete client re-write if a someone contributes the mockups.
If you happen to be a designer and want to contribute please hope on [#hammond](irc://irc.gnome.org/#hammond) and get in touch with us.
## Contributing
There alot of thins yet to be done.
@ -96,8 +113,8 @@ There are also some minor tasks tagged with `TODO:` and `FIXME:` in the source c
```sh
$ tree -d
├── assets # png's used in the README.md
├── hammond-data # Storate related stuff, Sqlite db, XDG setup.
│   ├── migrations # Diesel migrations.
├── hammond-data # Storate related stuff, SQLite, XDG setup, RSS Parser.
│   ├── migrations # Diesel SQL migrations.
│   │   └── ...
│   ├── src
│   └── tests
@ -108,8 +125,8 @@ $ tree -d
│   ├── resources # GResources folder
│   │   └── gtk # Contains the glade.ui files.
│   └── src
│   ├── views # Currently only contains the Podcasts_view.
│   └── widgets # Contains custom widgets such as Podcast and Episode.
│   ├── views # Contains the Empty, Episodes and Shows view.
│   └── widgets # Contains custom widgets such as Show and Episode.
```
## A note about the project's name
@ -120,9 +137,9 @@ It has nothing to do with the horrible headlines on the news.
## Acknowledgments
Hammond's design is heavily insired by [Gnome-Music](https://wiki.gnome.org/Design/Apps/Music) and [Vocal](http://vocalproject.net/).
Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Design/Apps/Music) and [Vocal](http://vocalproject.net/).
We also copied some elements from [Gnome-news](https://wiki.gnome.org/Design/Apps/Potential/News).
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.

29
TODO.md
View File

@ -1,45 +1,32 @@
## TODOs:
# TODOs
## Planned Features
## Priorities:
## Priorities
- [ ] Unplayed Only and Downloaded only view.
- [ ] Auto-updater
- [ ] OPML import/export // Probably need to create a crate.
**Proper Desing Mockups for the Gtk Client:**
- [ ] Re-design EpisodeWidget.
- [ ] Re-design PodcastWidget.
- [ ] Polish the flowbox_child banner.
## Second:
## Second
- [ ] Make use of file metadas, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull.
- [ ] Notifications
- [ ] Episode queue
- [ ] Embedded player
- [ ] MPRIS integration
- [ ] Search Implementation
## Third
## Third:
- [ ] Download Queue
- [ ] Download Queue
- [ ] Ability to Stream content on demand
- [ ] soundcloud and itunes feeds // [This](http://getrssfeed.com) seems intresting.
- [ ] Integrate with Itunes API for various crap
- [ ] YoutubeFeeds
- [ ] Integrate with Itunes API for various crap?
- [ ] YoutubeFeeds?
## Rest Tasks
**Would be nice:**
- [ ] Make Podcast cover fetchng and loading not block the execution of the program at startup.
- [ ] Lazy evaluate episode loading based on the podcast_widget's view scrolling.
- [ ] Headerbar back button and stack switching
- [ ] Lazy evaluate episode loading based on the show_widget's scrolling.
**FIXME:**
- [ ] Fix Etag/Last-modified implementation. [#2](https://gitlab.gnome.org/alatiera/Hammond/issues/2)

BIN
assets/episodes_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

BIN
assets/show_widget.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
assets/shows_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

View File

@ -5,31 +5,41 @@ version = "0.1.0"
workspace = "../"
[dependencies]
ammonia = "1.0.0"
ammonia = "1.0.1"
chrono = "0.4.0"
derive_builder = "0.5.0"
derive_builder = "0.5.1"
dotenv = "0.10.1"
error-chain = "0.11.0"
itertools = "0.7.4"
itertools = "0.7.6"
lazy_static = "1.0.0"
log = "0.3.8"
r2d2 = "0.8.1"
r2d2-diesel = "0.99.0"
log = "0.4.1"
rayon = "0.9.0"
reqwest = "0.8.1"
reqwest = "0.8.4"
rfc822_sanitizer = "0.3.3"
rss = "1.2.1"
url = "1.6.0"
xdg = "2.1.0"
futures = "0.1.18"
hyper = "0.11.15"
tokio-core = "0.1.12"
hyper-tls = "0.1.2"
native-tls = "0.1.5"
futures-cpupool = "0.1.8"
num_cpus = "1.8.0"
[dependencies.diesel]
features = ["sqlite"]
version = "0.99.0"
features = ["sqlite", "r2d2"]
version = "1.1.1"
[dependencies.diesel_migrations]
features = ["sqlite"]
version = "0.99.0"
version = "1.1.0"
[dev-dependencies]
rand = "0.3.18"
rand = "0.4.2"
tempdir = "0.3.5"
criterion = "0.1.2"
[[bench]]
name = "bench"
harness = false

View File

@ -1,66 +1,125 @@
#![feature(test)]
#[macro_use]
extern crate criterion;
use criterion::Criterion;
extern crate diesel;
// extern crate futures;
// extern crate futures_cpupool;
extern crate hammond_data;
extern crate hyper;
extern crate hyper_tls;
extern crate rand;
extern crate rayon;
extern crate tokio_core;
// extern crate rayon;
extern crate rss;
extern crate tempdir;
extern crate test;
use rayon::prelude::*;
// use rayon::prelude::*;
use test::Bencher;
// use futures::future::*;
// use futures_cpupool::CpuPool;
use tokio_core::reactor::Core;
use hammond_data::FeedBuilder;
use hammond_data::Source;
use hammond_data::feed::{index, Feed};
use hammond_data::database::truncate_db;
use hammond_data::pipeline;
// use hammond_data::errors::*;
use std::io::BufReader;
// Big rss feed
const PCPER: &[u8] = include_bytes!("feeds/pcpermp3.xml");
const UNPLUGGED: &[u8] = include_bytes!("feeds/linuxunplugged.xml");
const RADIO: &[u8] = include_bytes!("feeds/coderradiomp3.xml");
const SNAP: &[u8] = include_bytes!("feeds/techsnapmp3.xml");
const LAS: &[u8] = include_bytes!("feeds/TheLinuxActionShow.xml");
// RSS feeds
const INTERCEPTED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-Intercepted.xml");
const INTERCEPTED_URL: &str = "https://web.archive.org/web/20180120083840if_/https://feeds.\
feedburner.com/InterceptedWithJeremyScahill";
static URLS: &[(&[u8], &str)] = &[
(PCPER, "https://www.pcper.com/rss/podcasts-mp3.rss"),
(UNPLUGGED, "http://feeds.feedburner.com/linuxunplugged"),
(RADIO, "https://feeds.feedburner.com/coderradiomp3"),
(SNAP, "https://feeds.feedburner.com/techsnapmp3"),
(LAS, "https://feeds2.feedburner.com/TheLinuxActionShow"),
const UNPLUGGED: &[u8] = include_bytes!("../tests/feeds/2018-01-20-LinuxUnplugged.xml");
const UNPLUGGED_URL: &str =
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged";
const TIPOFF: &[u8] = include_bytes!("../tests/feeds/2018-01-20-TheTipOff.xml");
const TIPOFF_URL: &str =
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// This feed has HUGE descripion and summary fields which can be very
// very expensive to parse.
const CODE: &[u8] = include_bytes!("../tests/feeds/2018-01-20-GreaterThanCode.xml");
const CODE_URL: &str =
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast";
// Relative small feed
const STARS: &[u8] = include_bytes!("../tests/feeds/2018-01-20-StealTheStars.xml");
const STARS_URL: &str =
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
static FEEDS: &[(&[u8], &str)] = &[
(INTERCEPTED, INTERCEPTED_URL),
(UNPLUGGED, UNPLUGGED_URL),
(TIPOFF, TIPOFF_URL),
(CODE, CODE_URL),
(STARS, STARS_URL),
];
fn index_urls() {
let feeds: Vec<_> = URLS.par_iter()
.map(|&(buff, url)| {
// Create and insert a Source into db
// 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", |b| {
b.iter(|| {
let sources = hammond_data::dbqueries::get_sources().unwrap();
pipeline::run(sources, true).unwrap();
})
});
truncate_db().unwrap();
}
fn bench_index_large_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://www.greaterthancode.com/feed/podcast";
let mut core = Core::new().unwrap();
c.bench_function("index_large_feed", |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(buff)).unwrap();
Feed::from_channel_source(chan, s)
let chan = rss::Channel::read_from(BufReader::new(CODE)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
.collect();
index(feeds);
}
#[bench]
fn bench_index_feeds(b: &mut Bencher) {
b.iter(|| {
index_urls();
});
truncate_db().unwrap();
}
#[bench]
fn bench_index_unchanged_feeds(b: &mut Bencher) {
// Index first so it will only bench the comparison test case.
index_urls();
fn bench_index_small_feed(c: &mut Criterion) {
truncate_db().unwrap();
let url = "https://rss.art19.com/steal-the-stars";
let mut core = Core::new().unwrap();
b.iter(|| {
for _ in 0..10 {
index_urls();
}
c.bench_function("index_small_feed", |b| {
b.iter(|| {
let s = Source::from_url(url).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(STARS)).unwrap();
let feed = FeedBuilder::default()
.channel(chan)
.source_id(s.id())
.build()
.unwrap();
core.run(feed.index()).unwrap();
})
});
truncate_db().unwrap();
}
criterion_group!(
benches,
bench_pipeline,
bench_index_large_feed,
bench_index_small_feed
);
criterion_main!(benches);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,4 +16,8 @@ CREATE TABLE episode (
podcast_id INTEGER NOT NULL
);
INSERT INTO episode SELECT * FROM old_table;
INSERT INTO episode (title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id)
SELECT title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id
FROM old_table;
Drop table old_table;

View File

@ -2,7 +2,7 @@ ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT UNIQUE,
uri TEXT,
local_uri TEXT,
description TEXT,
published_date TEXT,

View File

@ -0,0 +1,22 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
published_date TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id)
SELECT title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,23 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
published_date TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id)
SELECT title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,24 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
published_date TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, epoch, length, duration, guid, played, favorite, archive, podcast_id)
SELECT title, uri, local_uri, description, epoch, length, duration, guid, played, favorite, archive, podcast_id
FROM old_table;
Drop table old_table;

View File

@ -0,0 +1,23 @@
ALTER TABLE episode RENAME TO old_table;
CREATE TABLE episode (
title TEXT NOT NULL,
uri TEXT,
local_uri TEXT,
description TEXT,
epoch INTEGER NOT NULL DEFAULT 0,
length INTEGER,
duration INTEGER,
guid TEXT,
played INTEGER,
podcast_id INTEGER NOT NULL,
favorite INTEGER DEFAULT 0,
archive INTEGER DEFAULT 0,
PRIMARY KEY (title, podcast_id)
);
INSERT INTO episode (title, uri, local_uri, description, epoch, length, duration, guid, played, favorite, archive, podcast_id)
SELECT title, uri, local_uri, description, epoch, length, duration, guid, played, favorite, archive, podcast_id
FROM old_table;
Drop table old_table;

View File

@ -1,9 +1,11 @@
use r2d2_diesel::ConnectionManager;
use diesel::prelude::*;
use r2d2;
//! Database Setup. This is only public to help with some unit tests.
use diesel::prelude::*;
use diesel::r2d2;
use diesel::r2d2::ConnectionManager;
use std::path::PathBuf;
use std::io;
use std::path::PathBuf;
use errors::*;
@ -35,6 +37,7 @@ lazy_static! {
static ref DB_PATH: PathBuf = TEMPDIR.path().join("hammond.db");
}
/// Get an r2d2 `SqliteConnection`.
pub(crate) fn connection() -> Pool {
POOL.clone()
}
@ -57,8 +60,7 @@ fn init_pool(db_path: &str) -> Pool {
fn run_migration_on(connection: &SqliteConnection) -> Result<()> {
info!("Running DB Migrations...");
// embedded_migrations::run(connection)?;
embedded_migrations::run_with_output(connection, &mut io::stdout())?;
Ok(())
embedded_migrations::run_with_output(connection, &mut io::stdout()).map_err(From::from)
}
/// Reset the database into a clean state.

View File

@ -1,217 +1,336 @@
//! Random CRUD helper functions.
use diesel::prelude::*;
use diesel;
use models::queryables::{Episode, Podcast, Source};
use chrono::prelude::*;
use errors::*;
use diesel::prelude::*;
use diesel;
use diesel::dsl::exists;
use diesel::select;
use database::connection;
use errors::*;
use models::*;
pub fn get_sources() -> Result<Vec<Source>> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
Ok(source.load::<Source>(&*con)?)
source
.order((http_etag.asc(), last_modified.asc()))
.load::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcasts() -> Result<Vec<Podcast>> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
Ok(podcast.load::<Podcast>(&*con)?)
podcast
.order(title.asc())
.load::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_episodes() -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(episode.order(epoch.desc()).load::<Episode>(&*con)?)
episode
.order(epoch.desc())
.load::<Episode>(&con)
.map_err(From::from)
}
pub fn get_downloaded_episodes() -> Result<Vec<Episode>> {
pub(crate) fn get_downloaded_episodes() -> Result<Vec<EpisodeCleanerQuery>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(episode
episode
.select((rowid, local_uri, played))
.filter(local_uri.is_not_null())
.load::<Episode>(&*con)?)
.load::<EpisodeCleanerQuery>(&con)
.map_err(From::from)
}
pub fn get_played_episodes() -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
// pub(crate) fn get_played_episodes() -> Result<Vec<Episode>> {
// use schema::episode::dsl::*;
// let db = connection();
// let con = db.get()?;
// episode
// .filter(played.is_not_null())
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub(crate) fn get_played_cleaner_episodes() -> Result<Vec<EpisodeCleanerQuery>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(episode.filter(played.is_not_null()).load::<Episode>(&*con)?)
episode
.select((rowid, local_uri, played))
.filter(played.is_not_null())
.load::<EpisodeCleanerQuery>(&con)
.map_err(From::from)
}
pub fn get_episode_from_id(ep_id: i32) -> Result<Episode> {
pub fn get_episode_from_rowid(ep_id: i32) -> Result<Episode> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(episode
episode
.filter(rowid.eq(ep_id))
.get_result::<Episode>(&*con)?)
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub fn get_episode_local_uri_from_id(ep_id: i32) -> Result<Option<String>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(episode
episode
.filter(rowid.eq(ep_id))
.select(local_uri)
.get_result::<Option<String>>(&*con)?)
.get_result::<Option<String>>(&con)
.map_err(From::from)
}
pub fn get_episodes_with_limit(limit: u32) -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
pub fn get_episodes_widgets_with_limit(limit: u32) -> Result<Vec<EpisodeWidgetQuery>> {
use schema::episode;
let db = connection();
let con = db.get()?;
Ok(episode
.order(epoch.desc())
episode::table
.select((
episode::rowid,
episode::title,
episode::uri,
episode::local_uri,
episode::epoch,
episode::length,
episode::duration,
episode::played,
episode::podcast_id,
))
.order(episode::epoch.desc())
.limit(i64::from(limit))
.load::<Episode>(&*con)?)
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_id(pid: i32) -> Result<Podcast> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
Ok(podcast.filter(id.eq(pid)).get_result::<Podcast>(&*con)?)
podcast
.filter(id.eq(pid))
.get_result::<Podcast>(&con)
.map_err(From::from)
}
pub fn get_podcast_cover_from_id(pid: i32) -> Result<PodcastCoverQuery> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
podcast
.select((id, title, image_uri))
.filter(id.eq(pid))
.get_result::<PodcastCoverQuery>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(Episode::belonging_to(parent)
Episode::belonging_to(parent)
.order(epoch.desc())
.load::<Episode>(&*con)?)
.load::<Episode>(&con)
.map_err(From::from)
}
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
.filter(podcast_id.eq(parent.id()))
// .group_by(epoch)
.order(epoch.desc())
.load::<EpisodeWidgetQuery>(&con)
.map_err(From::from)
}
pub fn get_pd_unplayed_episodes(parent: &Podcast) -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(Episode::belonging_to(parent)
Episode::belonging_to(parent)
.filter(played.is_null())
.order(epoch.desc())
.load::<Episode>(&*con)?)
.load::<Episode>(&con)
.map_err(From::from)
}
pub fn get_pd_episodes_limit(parent: &Podcast, limit: u32) -> Result<Vec<Episode>> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
Ok(Episode::belonging_to(parent)
.order(epoch.desc())
.limit(i64::from(limit))
.load::<Episode>(&*con)?)
}
pub fn get_source_from_uri(uri_: &str) -> Result<Source> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
Ok(source.filter(uri.eq(uri_)).get_result::<Source>(&*con)?)
}
// pub fn get_podcast_from_title(title_: &str) -> QueryResult<Podcast> {
// use schema::podcast::dsl::*;
// pub(crate) fn get_pd_episodes_limit(parent: &Podcast, limit: u32) -> Result<Vec<Episode>> {
// use schema::episode::dsl::*;
// let db = connection();
// let con = db.get()?;
// podcast
// .filter(title.eq(title_))
// .get_result::<Podcast>(&*con)
// Episode::belonging_to(parent)
// .order(epoch.desc())
// .limit(i64::from(limit))
// .load::<Episode>(&con)
// .map_err(From::from)
// }
pub fn get_source_from_uri(uri_: &str) -> Result<Source> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
source
.filter(uri.eq(uri_))
.get_result::<Source>(&con)
.map_err(From::from)
}
pub fn get_podcast_from_source_id(sid: i32) -> Result<Podcast> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
Ok(podcast
podcast
.filter(source_id.eq(sid))
.get_result::<Podcast>(&*con)?)
.get_result::<Podcast>(&con)
.map_err(From::from)
}
// TODO: unhack me
pub fn get_episode_from_new_episode(
con: &SqliteConnection,
title_: &str,
pid: i32,
) -> QueryResult<Episode> {
pub fn get_episode_from_pk(title_: &str, pid: i32) -> Result<Episode> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.get_result::<Episode>(&*con)
.get_result::<Episode>(&con)
.map_err(From::from)
}
pub fn remove_feed(pd: &Podcast) -> Result<()> {
pub(crate) fn get_episode_minimal_from_pk(title_: &str, pid: i32) -> Result<EpisodeMinimal> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
episode
.select((rowid, title, uri, epoch, duration, guid, podcast_id))
.filter(title.eq(title_))
.filter(podcast_id.eq(pid))
.get_result::<EpisodeMinimal>(&con)
.map_err(From::from)
}
pub(crate) fn remove_feed(pd: &Podcast) -> Result<()> {
let db = connection();
let con = db.get()?;
con.transaction(|| -> Result<()> {
delete_source(&con, pd.source_id())?;
delete_podcast(&con, *pd.id())?;
delete_podcast_episodes(&con, *pd.id())?;
delete_podcast(&con, pd.id())?;
delete_podcast_episodes(&con, pd.id())?;
info!("Feed removed from the Database.");
Ok(())
})
}
pub fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
fn delete_source(con: &SqliteConnection, source_id: i32) -> QueryResult<usize> {
use schema::source::dsl::*;
diesel::delete(source.filter(id.eq(source_id))).execute(&*con)
diesel::delete(source.filter(id.eq(source_id))).execute(con)
}
pub fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
fn delete_podcast(con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
use schema::podcast::dsl::*;
diesel::delete(podcast.filter(id.eq(podcast_id))).execute(&*con)
diesel::delete(podcast.filter(id.eq(podcast_id))).execute(con)
}
pub fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
fn delete_podcast_episodes(con: &SqliteConnection, parent_id: i32) -> QueryResult<usize> {
use schema::episode::dsl::*;
diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(&*con)
diesel::delete(episode.filter(podcast_id.eq(parent_id))).execute(con)
}
pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize> {
pub fn source_exists(url: &str) -> Result<bool> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(source.filter(uri.eq(url))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn podcast_exists(source_id_: i32) -> Result<bool> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(podcast.filter(source_id.eq(source_id_))))
.get_result(&con)
.map_err(From::from)
}
#[cfg_attr(rustfmt, rustfmt_skip)]
pub(crate) fn episode_exists(title_: &str, podcast_id_: i32) -> Result<bool> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
select(exists(episode.filter(podcast_id.eq(podcast_id_)).filter(title.eq(title_))))
.get_result(&con)
.map_err(From::from)
}
pub(crate) fn index_new_episodes(eps: &[NewEpisode]) -> Result<()> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(episode)
.values(eps)
.execute(&*con)
.map_err(From::from)
.map(|_| ())
}
pub fn update_none_to_played_now(parent: &Podcast) -> Result<usize> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
let epoch_now = Utc::now().timestamp() as i32;
con.transaction(|| -> Result<usize> {
Ok(
diesel::update(Episode::belonging_to(parent).filter(played.is_null()))
.set(played.eq(Some(epoch_now)))
.execute(&*con)?,
)
diesel::update(Episode::belonging_to(parent).filter(played.is_null()))
.set(played.eq(Some(epoch_now)))
.execute(&con)
.map_err(From::from)
})
}

View File

@ -1,18 +1,25 @@
use diesel::result;
use diesel;
use diesel::r2d2;
use diesel_migrations::RunMigrationsError;
use rss;
use hyper;
use native_tls;
use reqwest;
use r2d2;
use rss;
use url;
use std::io;
error_chain! {
foreign_links {
R2D2Error(r2d2::Error);
DieselResultError(result::Error);
DieselResultError(diesel::result::Error);
DieselMigrationError(RunMigrationsError);
R2D2Error(r2d2::Error);
R2D2PoolError(r2d2::PoolError);
RSSError(rss::Error);
ReqError(reqwest::Error);
HyperError(hyper::Error);
UrlError(url::ParseError);
TLSError(native_tls::Error);
IoError(io::Error);
}
}

View File

@ -1,287 +1,208 @@
//! Index and retrieve Feeds.
//! Index Feeds.
use rayon::prelude::*;
use diesel::prelude::*;
use rayon::iter::IntoParallelIterator;
use diesel::associations::Identifiable;
use futures::future::*;
use itertools::{Either, Itertools};
use rss;
use dbqueries;
use parser;
use models::queryables::{Episode, Podcast, Source};
use models::insertables::{NewEpisode, NewPodcast};
use database::connection;
use errors::*;
use models::{IndexState, Update};
use models::{NewEpisode, NewPodcast, Podcast};
use pipeline::*;
#[derive(Debug)]
/// Wrapper struct that hold a `Source` and the `rss::Channel`
type InsertUpdate = (Vec<NewEpisode>, Vec<Option<(NewEpisode, i32)>>);
/// Wrapper struct that hold a `Source` id and the `rss::Channel`
/// that corresponds to the `Source.uri` field.
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub struct Feed {
/// The `rss::Channel` parsed from the `Source` uri.
channel: rss::Channel,
source: Source,
/// The `Source` id where the xml `rss::Channel` came from.
source_id: i32,
}
impl Feed {
/// Constructor that consumes a `Source` and returns the corresponding `Feed` struct.
pub fn from_source(s: Source) -> Result<Feed> {
s.into_feed()
/// Index the contents of the RSS `Feed` into the database.
pub fn index(self) -> Box<Future<Item = (), Error = Error> + Send> {
let fut = self.parse_podcast_async()
.and_then(|pd| pd.to_podcast())
.and_then(move |pd| self.index_channel_items(&pd));
Box::new(fut)
}
/// Constructor that consumes a `Source` and a `rss::Channel` returns a `Feed` struct.
pub fn from_channel_source(chan: rss::Channel, s: Source) -> Feed {
Feed {
channel: chan,
source: s,
}
fn parse_podcast(&self) -> NewPodcast {
NewPodcast::new(&self.channel, self.source_id)
}
fn index(&self) -> Result<()> {
let pd = self.get_podcast()?;
self.index_channel_items(&pd)
fn parse_podcast_async(&self) -> Box<Future<Item = NewPodcast, Error = Error> + Send> {
Box::new(ok(self.parse_podcast()))
}
// #[allow(dead_code)]
// fn index_channel(&self) -> Result<()> {
// self.parse_channel().index()?;
// Ok(())
// }
// TODO: Refactor transcactions and find a way to do it in parallel.
fn index_channel_items(&self, pd: &Podcast) -> Result<()> {
let episodes = self.parse_channel_items(pd);
let db = connection();
let con = db.get()?;
let _ = con.transaction::<(), Error, _>(|| {
episodes.into_iter().for_each(|x| {
let e = x.index(&con);
if let Err(err) = e {
error!("Failed to index episode: {:?}.", x.title());
error!("Error msg: {}", err);
};
fn index_channel_items(&self, pd: &Podcast) -> Box<Future<Item = (), Error = Error> + Send> {
let fut = self.get_stuff(pd)
.and_then(|(insert, update)| {
if !insert.is_empty() {
info!("Indexing {} episodes.", insert.len());
dbqueries::index_new_episodes(insert.as_slice())?;
}
Ok((insert, update))
})
.map(|(_, update)| {
if !update.is_empty() {
info!("Updating {} episodes.", update.len());
// see get_stuff for more
update
.into_iter()
.filter_map(|x| x)
.for_each(|(ref ep, rowid)| {
if let Err(err) = ep.update(rowid) {
error!("Failed to index episode: {:?}.", ep.title());
error!("Error msg: {}", err);
};
})
}
});
Ok(())
});
Ok(())
Box::new(fut)
}
fn parse_channel(&self) -> NewPodcast {
parser::new_podcast(&self.channel, *self.source.id())
fn get_stuff(&self, pd: &Podcast) -> Box<Future<Item = InsertUpdate, Error = Error> + 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 => bail!("Nothing to do here."),
_ => 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)))
}
fn parse_channel_items(&self, pd: &Podcast) -> Vec<NewEpisode> {
let items = self.channel.items();
let new_episodes: Vec<_> = items
.into_par_iter()
.filter_map(|item| parser::new_episode(item, *pd.id()).ok())
.collect();
new_episodes
}
fn get_podcast(&self) -> Result<Podcast> {
self.parse_channel().into_podcast()
}
#[allow(dead_code)]
fn get_episodes(&self) -> Result<Vec<Episode>> {
let pd = self.get_podcast()?;
let eps = self.parse_channel_items(&pd);
let db = connection();
let con = db.get()?;
// TODO: Make it parallel
// This returns only the episodes in the xml feed.
let episodes: Vec<_> = eps.into_iter()
.filter_map(|ep| ep.into_episode(&con).ok())
.collect();
Ok(episodes)
// This would return every episode of the feed from the db.
// self.index_channel_items(&pd)?;
// Ok(dbqueries::get_pd_episodes(&pd)?)
}
}
/// Use's `fetch_all` to retrieve a list of `Feed`s and use index them using `feed::index`.
pub fn index_all() -> Result<()> {
let feeds = fetch_all()?;
index(feeds);
Ok(())
}
/// Handle the indexing of a feed `F` into the Database.
///
/// Consume a `ParallelIterator<Feed>` and index it.
pub fn index<F: IntoParallelIterator<Item = Feed>>(feeds: F) {
feeds.into_par_iter().for_each(|f| {
let e = f.index();
if e.is_err() {
error!("Error While trying to update the database.");
error!("Error msg: {}", e.unwrap_err());
};
});
info!("Indexing done.");
}
/// Retrieve a list of all the `Source` in the database,
/// then use `feed::fetch` to convert them into `Feed`s
/// and return them.
pub fn fetch_all() -> Result<Vec<Feed>> {
let feeds = dbqueries::get_sources()?;
Ok(fetch(feeds))
}
/// Consume a `ParallelIterator<Source>` and return a list of `Feed`s.
pub fn fetch<F: IntoParallelIterator<Item = Source>>(feeds: F) -> Vec<Feed> {
let results: Vec<_> = feeds
.into_par_iter()
.filter_map(|x| {
let uri = x.uri().to_owned();
let feed = Feed::from_source(x).ok();
if feed.is_none() {
error!("Error While trying to fetch from source url: {}.", uri);
}
feed
})
.collect();
results
}
#[cfg(test)]
mod tests {
use rss::Channel;
use tokio_core::reactor::Core;
use Source;
use database::truncate_db;
use dbqueries;
use utils::get_feed;
use std::fs;
use std::io::BufReader;
use database::truncate_db;
use super::*;
#[test]
/// Insert feeds and update/index them.
fn test_index_loop() {
truncate_db().unwrap();
let inpt = vec![
"https://request-for-explanation.github.io/podcast/rss.xml",
"https://feeds.feedburner.com/InterceptedWithJeremyScahill",
"http://feeds.propublica.org/propublica/podcast",
"http://feeds.feedburner.com/linuxunplugged",
];
inpt.iter().for_each(|url| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
index_all().unwrap();
// Run again to cover Unique constrains erros.
index_all().unwrap();
}
#[test]
/// Insert feeds and update/index them.
fn test_fetch_loop() {
truncate_db().unwrap();
let inpt = vec![
"https://request-for-explanation.github.io/podcast/rss.xml",
"https://feeds.feedburner.com/InterceptedWithJeremyScahill",
"http://feeds.propublica.org/propublica/podcast",
"http://feeds.feedburner.com/linuxunplugged",
];
inpt.iter().for_each(|url| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
fetch_all().unwrap();
}
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test]
fn test_complete_index() {
// vec of (path, url) tuples.
let urls = vec![
(
"tests/feeds/Intercepted.xml",
"https://feeds.feedburner.com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/LinuxUnplugged.xml",
"http://feeds.feedburner.com/linuxunplugged",
),
(
"tests/feeds/TheBreakthrough.xml",
"http://feeds.propublica.org/propublica/podcast",
),
(
"tests/feeds/R4Explanation.xml",
"https://request-for-explanation.github.io/podcast/rss.xml",
),
];
truncate_db().unwrap();
let feeds: Vec<_> = urls.iter()
let feeds: Vec<_> = URLS.iter()
.map(|&(path, url)| {
// Create and insert a Source into db
let s = Source::from_url(url).unwrap();
// open the xml file
let feed = fs::File::open(path).unwrap();
// parse it into a channel
let chan = rss::Channel::read_from(BufReader::new(feed)).unwrap();
Feed::from_channel_source(chan, s)
get_feed(path, s.id())
})
.collect();
let mut core = Core::new().unwrap();
// Index the channels
index(feeds);
let list: Vec<_> = feeds.into_iter().map(|x| x.index()).collect();
let _foo = core.run(join_all(list));
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 4);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 4);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 274);
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
#[test]
fn test_partial_index_podcast() {
fn test_feed_parse_podcast() {
truncate_db().unwrap();
let url = "https://feeds.feedburner.com/InterceptedWithJeremyScahill";
let s1 = Source::from_url(url).unwrap();
let s2 = Source::from_url(url).unwrap();
assert_eq!(s1, s2);
assert_eq!(s1.id(), s2.id());
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let f1 = s1.into_feed().unwrap();
let f2 = s2.into_feed().unwrap();
let file = fs::File::open(path).unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let p1 = f1.get_podcast().unwrap();
let p2 = {
f2.index().unwrap();
f2.get_podcast().unwrap()
};
assert_eq!(p1, p2);
assert_eq!(p1.id(), p2.id());
assert_eq!(p1.source_id(), p2.source_id());
let pd = NewPodcast::new(&channel, 42);
assert_eq!(feed.parse_podcast(), pd);
}
let eps1 = f1.get_episodes().unwrap();
let eps2 = {
f2.index().unwrap();
f2.get_episodes().unwrap()
};
#[test]
fn test_feed_index_channel_items() {
truncate_db().unwrap();
eps1.into_par_iter().zip(eps2).into_par_iter().for_each(
|(ep1, ep2): (Episode, Episode)| {
assert_eq!(ep1, ep2);
assert_eq!(ep1.id(), ep2.id());
assert_eq!(ep1.podcast_id(), ep2.podcast_id());
},
);
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
feed.index_channel_items(&pd).wait().unwrap();
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 1);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 43);
}
#[test]
fn test_feed_get_stuff() {
truncate_db().unwrap();
let path = "tests/feeds/2018-01-20-Intercepted.xml";
let feed = get_feed(path, 42);
let pd = feed.parse_podcast().to_podcast().unwrap();
let (insert, update) = feed.get_stuff(&pd).wait().unwrap();
assert_eq!(43, insert.len());
assert_eq!(0, update.len());
// TODO: find or create a feed to test updates too.
}
}

View File

@ -1,67 +1,71 @@
#![recursion_limit = "1024"]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
#![cfg_attr(feature = "cargo-clippy", allow(blacklisted_name))]
#![cfg_attr(feature = "clippy",
warn(option_unwrap_used, result_unwrap_used, print_stdout,
wrong_pub_self_convention, mut_mut, non_ascii_literal, similar_names,
unicode_not_nfc, enum_glob_use, if_not_else, items_after_statements,
used_underscore_binding))]
#![cfg_attr(all(test, feature = "clippy"), allow(option_unwrap_used, result_unwrap_used))]
//! A libraty for parsing, indexing and retrieving podcast Feeds,
//! into and from a Database.
//! FIXME: Docs
#![allow(unknown_lints)]
#![deny(bad_style, const_err, dead_code, improper_ctypes, legacy_directory_ownership,
non_shorthand_field_patterns, no_mangle_generic_items, overflowing_literals,
path_statements, patterns_in_fns_without_body, plugin_as_library, private_in_public,
private_no_mangle_fns, private_no_mangle_statics, safe_extern_statics,
unconditional_recursion, unions_with_drop_fields, unused, unused_allocation,
unused_comparisons, unused_parens, while_true)]
#![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts,
unused_extern_crates)]
unconditional_recursion, unions_with_drop_fields, unused_allocation, unused_comparisons,
unused_parens, while_true)]
#![deny(missing_debug_implementations, missing_docs, trivial_casts, trivial_numeric_casts)]
#![deny(unused_extern_crates, unused)]
#[macro_use]
extern crate error_chain;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
// #![feature(conservative_impl_trait)]
#[macro_use]
extern crate derive_builder;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_migrations;
#[macro_use]
extern crate error_chain;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
extern crate ammonia;
extern crate chrono;
extern crate futures;
extern crate futures_cpupool;
extern crate hyper;
extern crate hyper_tls;
extern crate itertools;
extern crate r2d2;
extern crate r2d2_diesel;
extern crate native_tls;
extern crate num_cpus;
extern crate rayon;
extern crate reqwest;
extern crate rfc822_sanitizer;
extern crate rss;
extern crate tokio_core;
extern crate url;
extern crate xdg;
#[allow(missing_docs)]
pub mod dbqueries;
pub mod utils;
pub mod feed;
#[allow(missing_docs)]
pub mod errors;
pub(crate) mod database;
pub mod utils;
pub mod database;
pub mod pipeline;
pub(crate) mod models;
mod feed;
mod parser;
mod schema;
pub use models::queryables::{Episode, Podcast, Source};
pub use feed::{Feed, FeedBuilder};
pub use models::{Episode, EpisodeWidgetQuery, Podcast, PodcastCoverQuery, Source};
pub use models::Save;
/// [XDG Base Direcotory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) Paths.
#[allow(missing_debug_implementations)]

View File

@ -0,0 +1,493 @@
use chrono::prelude::*;
use diesel;
use diesel::SaveChangesDsl;
use diesel::prelude::*;
use database::connection;
use errors::*;
use models::{Podcast, Save};
use schema::episode;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[belongs_to(Podcast, foreign_key = "podcast_id")]
#[derive(Debug, Clone)]
/// Diesel Model of the episode table.
pub struct Episode {
rowid: i32,
title: String,
uri: Option<String>,
local_uri: Option<String>,
description: Option<String>,
epoch: i32,
length: Option<i32>,
duration: Option<i32>,
guid: Option<String>,
played: Option<i32>,
favorite: bool,
archive: bool,
podcast_id: i32,
}
impl Save<Episode> for Episode {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<Episode> {
let db = connection();
let tempdb = db.get()?;
self.save_changes::<Episode>(&*tempdb).map_err(From::from)
}
}
impl Episode {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
}
/// Get the value of the `title` field.
pub fn title(&self) -> &str {
&self.title
}
/// Set the `title`.
pub fn set_title(&mut self, value: &str) {
self.title = value.to_string();
}
/// Get the value of the `uri`.
///
/// Represents the url(usually) that the media file will be located at.
pub fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
/// Set the `uri`.
pub fn set_uri(&mut self, value: Option<&str>) {
self.uri = value.map(|x| x.to_string());
}
/// Get the value of the `local_uri`.
///
/// Represents the local uri,usually filesystem path,
/// that the media file will be located at.
pub fn local_uri(&self) -> Option<&str> {
self.local_uri.as_ref().map(|s| s.as_str())
}
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Get the `description`.
pub fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str())
}
/// Set the `description`.
pub fn set_description(&mut self, value: Option<&str>) {
self.description = value.map(|x| x.to_string());
}
/// Get the Episode's `guid`.
pub fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
/// Set the `guid`.
pub fn set_guid(&mut self, value: Option<&str>) {
self.guid = value.map(|x| x.to_string());
}
/// Get the `epoch` value.
///
/// Retrieved from the rss Item publish date.
/// Value is set to Utc whenever possible.
pub fn epoch(&self) -> i32 {
self.epoch
}
/// Set the `epoch`.
pub fn set_epoch(&mut self, value: i32) {
self.epoch = value;
}
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
pub fn duration(&self) -> Option<i32> {
self.duration
}
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
pub fn played(&self) -> Option<i32> {
self.played
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
///
/// If true, the download cleanr will ignore the episode
/// and the corresponding media value will never be automaticly deleted.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Episode`.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
pub fn set_played_now(&mut self) -> Result<()> {
let epoch = Utc::now().timestamp() as i32;
self.set_played(Some(epoch));
self.save().map(|_| ())
}
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for constructing `EpisodeWidgets`.
pub struct EpisodeWidgetQuery {
rowid: i32,
title: String,
uri: Option<String>,
local_uri: Option<String>,
epoch: i32,
length: Option<i32>,
duration: Option<i32>,
played: Option<i32>,
// favorite: bool,
// archive: bool,
podcast_id: i32,
}
impl From<Episode> for EpisodeWidgetQuery {
fn from(e: Episode) -> EpisodeWidgetQuery {
EpisodeWidgetQuery {
rowid: e.rowid,
title: e.title,
uri: e.uri,
local_uri: e.local_uri,
epoch: e.epoch,
length: e.length,
duration: e.duration,
played: e.played,
podcast_id: e.podcast_id,
}
}
}
impl Save<usize> for EpisodeWidgetQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<usize> {
use schema::episode::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl EpisodeWidgetQuery {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
}
/// Get the value of the `title` field.
pub fn title(&self) -> &str {
&self.title
}
/// Get the value of the `uri`.
///
/// Represents the url(usually) that the media file will be located at.
pub fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
/// Get the value of the `local_uri`.
///
/// Represents the local uri,usually filesystem path,
/// that the media file will be located at.
pub fn local_uri(&self) -> Option<&str> {
self.local_uri.as_ref().map(|s| s.as_str())
}
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Get the `epoch` value.
///
/// Retrieved from the rss Item publish date.
/// Value is set to Utc whenever possible.
pub fn epoch(&self) -> i32 {
self.epoch
}
/// Get the `length`.
///
/// The number represents the size of the file in bytes.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
pub fn duration(&self) -> Option<i32> {
self.duration
}
/// Set the `duration`.
pub fn set_duration(&mut self, value: Option<i32>) {
self.duration = value;
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
pub fn played(&self) -> Option<i32> {
self.played
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
// /// Represents the archiving policy for the episode.
// pub fn archive(&self) -> bool {
// self.archive
// }
// /// Set the `archive` policy.
// ///
// /// If true, the download cleanr will ignore the episode
// /// and the corresponding media value will never be automaticly deleted.
// pub fn set_archive(&mut self, b: bool) {
// self.archive = b
// }
// /// Get the `favorite` status of the `Episode`.
// pub fn favorite(&self) -> bool {
// self.favorite
// }
// /// Set `favorite` status.
// pub fn set_favorite(&mut self, b: bool) {
// self.favorite = b
// }
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
pub fn set_played_now(&mut self) -> Result<()> {
let epoch = Utc::now().timestamp() as i32;
self.set_played(Some(epoch));
self.save().map(|_| ())
}
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used internal with the `utils::checkup` function.
pub struct EpisodeCleanerQuery {
rowid: i32,
local_uri: Option<String>,
played: Option<i32>,
}
impl Save<usize> for EpisodeCleanerQuery {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<usize> {
use schema::episode::dsl::*;
let db = connection();
let tempdb = db.get()?;
diesel::update(episode.filter(rowid.eq(self.rowid)))
.set(self)
.execute(&*tempdb)
.map_err(From::from)
}
}
impl From<Episode> for EpisodeCleanerQuery {
fn from(e: Episode) -> EpisodeCleanerQuery {
EpisodeCleanerQuery {
rowid: e.rowid(),
local_uri: e.local_uri,
played: e.played,
}
}
}
impl EpisodeCleanerQuery {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
}
/// Get the value of the `local_uri`.
///
/// Represents the local uri,usually filesystem path,
/// that the media file will be located at.
pub fn local_uri(&self) -> Option<&str> {
self.local_uri.as_ref().map(|s| s.as_str())
}
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
pub fn played(&self) -> Option<i32> {
self.played
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
}
#[derive(Queryable, AsChangeset, PartialEq)]
#[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[derive(Debug, Clone)]
/// Diesel Model to be used for FIXME.
pub struct EpisodeMinimal {
rowid: i32,
title: String,
uri: Option<String>,
epoch: i32,
duration: Option<i32>,
guid: Option<String>,
podcast_id: i32,
}
impl From<Episode> for EpisodeMinimal {
fn from(e: Episode) -> Self {
EpisodeMinimal {
rowid: e.rowid,
title: e.title,
uri: e.uri,
guid: e.guid,
epoch: e.epoch,
duration: e.duration,
podcast_id: e.podcast_id,
}
}
}
impl EpisodeMinimal {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
}
/// Get the value of the `title` field.
pub fn title(&self) -> &str {
&self.title
}
/// Get the value of the `uri`.
///
/// Represents the url(usually) that the media file will be located at.
pub fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
/// Get the Episode's `guid`.
pub fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
/// Get the `epoch` value.
///
/// Retrieved from the rss Item publish date.
/// Value is set to Utc whenever possible.
pub fn epoch(&self) -> i32 {
self.epoch
}
/// Get the `duration` value.
///
/// The number represents the duration of the item/episode in seconds.
pub fn duration(&self) -> Option<i32> {
self.duration
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
}

View File

@ -1,261 +0,0 @@
#![allow(unused_mut)]
use diesel::prelude::*;
use schema::{episode, podcast, source};
use models::queryables::{Episode, Podcast, Source};
use utils::url_cleaner;
use errors::*;
use dbqueries;
use diesel;
use database::connection;
trait Insert {
fn insert(&self, &SqliteConnection) -> QueryResult<usize>;
}
trait Update {
fn update(&self, &SqliteConnection, i32) -> QueryResult<usize>;
}
#[derive(Insertable)]
#[table_name = "source"]
#[derive(Debug, Clone, Default, Builder)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewSource {
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl Insert for NewSource {
fn insert(&self, con: &SqliteConnection) -> QueryResult<usize> {
use schema::source::dsl::*;
diesel::insert_into(source).values(self).execute(&*con)
}
}
impl NewSource {
pub(crate) fn new_with_uri(uri: &str) -> NewSource {
let uri = url_cleaner(uri);
NewSource {
uri,
last_modified: None,
http_etag: None,
}
}
fn index(&self) -> Result<()> {
let db = connection();
let con = db.get()?;
// Throw away the result like `insert or ignore`
// Diesel deos not support `insert or ignore` yet.
let _ = self.insert(&con);
Ok(())
}
// Look out for when tryinto lands into stable.
pub(crate) fn into_source(self) -> Result<Source> {
self.index()?;
dbqueries::get_source_from_uri(&self.uri)
}
}
#[derive(Insertable, AsChangeset)]
#[table_name = "podcast"]
#[derive(Debug, Clone, Default, Builder)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewPodcast {
title: String,
link: String,
description: String,
image_uri: Option<String>,
source_id: i32,
}
impl Insert for NewPodcast {
fn insert(&self, con: &SqliteConnection) -> QueryResult<usize> {
use schema::podcast::dsl::*;
diesel::insert_into(podcast).values(self).execute(&*con)
}
}
impl Update for NewPodcast {
fn update(&self, con: &SqliteConnection, podcast_id: i32) -> QueryResult<usize> {
use schema::podcast::dsl::*;
info!("Updating {}", self.title);
diesel::update(podcast.filter(id.eq(podcast_id)))
.set(self)
.execute(&*con)
}
}
impl NewPodcast {
// Look out for when tryinto lands into stable.
pub(crate) fn into_podcast(self) -> Result<Podcast> {
self.index()?;
Ok(dbqueries::get_podcast_from_source_id(self.source_id)?)
}
pub(crate) fn index(&self) -> Result<()> {
let pd = dbqueries::get_podcast_from_source_id(self.source_id);
let db = connection();
let con = db.get()?;
match pd {
Ok(foo) => {
if foo.source_id() != self.source_id {
error!("NSPD sid: {}, SPD sid: {}", self.source_id, foo.source_id());
};
if (foo.link() != self.link) || (foo.title() != self.title)
|| (foo.image_uri() != self.image_uri.as_ref().map(|x| x.as_str()))
{
self.update(&con, *foo.id())?;
}
}
Err(_) => {
self.insert(&con)?;
}
}
Ok(())
}
}
#[allow(dead_code)]
// Ignore the following geters. They are used in unit tests mainly.
impl NewPodcast {
pub(crate) fn source_id(&self) -> i32 {
self.source_id
}
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn link(&self) -> &str {
&self.link
}
pub(crate) fn description(&self) -> &str {
&self.description
}
pub(crate) fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[derive(Debug, Clone, Default, Builder)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewEpisode {
title: String,
uri: Option<String>,
description: Option<String>,
published_date: Option<String>,
length: Option<i32>,
guid: Option<String>,
epoch: i32,
podcast_id: i32,
}
impl Insert for NewEpisode {
fn insert(&self, con: &SqliteConnection) -> QueryResult<usize> {
use schema::episode::dsl::*;
diesel::insert_into(episode).values(self).execute(&*con)
}
}
impl Update for NewEpisode {
fn update(&self, con: &SqliteConnection, episode_id: i32) -> QueryResult<usize> {
use schema::episode::dsl::*;
info!("Updating {:?}", self.title);
diesel::update(episode.filter(rowid.eq(episode_id)))
.set(self)
.execute(&*con)
}
}
impl NewEpisode {
// TODO: Refactor into batch indexes instead.
pub(crate) fn into_episode(self, con: &SqliteConnection) -> Result<Episode> {
self.index(con)?;
Ok(dbqueries::get_episode_from_new_episode(
con,
&self.title,
self.podcast_id,
)?)
}
pub(crate) fn index(&self, con: &SqliteConnection) -> QueryResult<()> {
// TODO: Change me
let ep = dbqueries::get_episode_from_new_episode(con, &self.title, self.podcast_id);
match ep {
Ok(foo) => {
if foo.podcast_id() != self.podcast_id {
error!("NEP pid: {}, EP pid: {}", self.podcast_id, foo.podcast_id());
};
if foo.title() != self.title.as_str() || foo.epoch() != self.epoch
|| foo.uri() != self.uri.as_ref().map(|s| s.as_str())
{
self.update(con, foo.rowid())?;
}
}
Err(_) => {
self.insert(con)?;
}
}
Ok(())
}
}
#[allow(dead_code)]
// Ignore the following getters. They are used in unit tests mainly.
impl NewEpisode {
pub(crate) fn title(&self) -> &str {
self.title.as_ref()
}
pub(crate) fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
pub(crate) fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str())
}
pub(crate) fn published_date(&self) -> Option<&str> {
self.published_date.as_ref().map(|s| s.as_str())
}
pub(crate) fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
pub(crate) fn epoch(&self) -> i32 {
self.epoch
}
pub(crate) fn length(&self) -> Option<i32> {
self.length
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
}
}

View File

@ -1,2 +1,51 @@
pub(crate) mod insertables;
pub(crate) mod queryables;
mod new_episode;
mod new_podcast;
mod new_source;
mod episode;
mod podcast;
mod source;
// use futures::prelude::*;
// use futures::future::*;
pub(crate) use self::episode::EpisodeCleanerQuery;
pub(crate) use self::new_episode::{NewEpisode, NewEpisodeMinimal};
pub(crate) use self::new_podcast::NewPodcast;
pub(crate) use self::new_source::NewSource;
#[cfg(test)]
pub(crate) use self::new_episode::NewEpisodeBuilder;
#[cfg(test)]
pub(crate) use self::new_podcast::NewPodcastBuilder;
pub use self::episode::{Episode, EpisodeMinimal, EpisodeWidgetQuery};
pub use self::podcast::{Podcast, PodcastCoverQuery};
pub use self::source::Source;
use errors::*;
#[derive(Debug, Clone, PartialEq)]
pub enum IndexState<T> {
Index(T),
Update((T, i32)),
NotChanged,
}
pub trait Insert {
fn insert(&self) -> Result<()>;
}
pub trait Update {
fn update(&self, i32) -> Result<()>;
}
pub trait Index: Insert + Update {
fn index(&self) -> Result<()>;
}
/// FIXME: DOCS
pub trait Save<T> {
/// Helper method to easily save/"sync" current state of a diesel model to the Database.
fn save(&self) -> Result<T>;
}

View File

@ -0,0 +1,639 @@
use diesel::prelude::*;
use diesel;
use schema::episode;
use ammonia;
use rfc822_sanitizer::parse_from_rfc2822_with_fallback as parse_rfc822;
use rss;
use database::connection;
use dbqueries;
use errors::*;
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
use parser;
use utils::{replace_extra_spaces, url_cleaner};
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewEpisode {
title: String,
uri: Option<String>,
description: Option<String>,
length: Option<i32>,
duration: Option<i32>,
guid: Option<String>,
epoch: i32,
podcast_id: i32,
}
impl From<NewEpisodeMinimal> for NewEpisode {
fn from(e: NewEpisodeMinimal) -> Self {
NewEpisodeBuilder::default()
.title(e.title)
.uri(e.uri)
.duration(e.duration)
.epoch(e.epoch)
.podcast_id(e.podcast_id)
.guid(e.guid)
.build()
.unwrap()
}
}
impl Insert for NewEpisode {
fn insert(&self) -> Result<()> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
info!("Inserting {:?}", self.title);
diesel::insert_into(episode)
.values(self)
.execute(&con)
.map_err(From::from)
.map(|_| ())
}
}
impl Update for NewEpisode {
fn update(&self, episode_id: i32) -> Result<()> {
use schema::episode::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {:?}", self.title);
diesel::update(episode.filter(rowid.eq(episode_id)))
.set(self)
.execute(&con)
.map_err(From::from)
.map(|_| ())
}
}
impl Index for NewEpisode {
// Does not update the episode description if it's the only thing that has changed.
fn index(&self) -> Result<()> {
let exists = dbqueries::episode_exists(self.title(), self.podcast_id())?;
if exists {
let other = dbqueries::get_episode_minimal_from_pk(self.title(), self.podcast_id())?;
if self != &other {
self.update(other.rowid())
} else {
Ok(())
}
} else {
self.insert()
}
}
}
impl PartialEq<EpisodeMinimal> for NewEpisode {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
}
}
impl PartialEq<Episode> for NewEpisode {
fn eq(&self, other: &Episode) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
&& (self.description() == other.description())
&& (self.length() == other.length())
}
}
impl NewEpisode {
/// Parses an `rss::Item` into a `NewEpisode` Struct.
#[allow(dead_code)]
pub(crate) fn new(item: &rss::Item, podcast_id: i32) -> Result<Self> {
NewEpisodeMinimal::new(item, podcast_id).map(|ep| ep.into_new_episode(item))
}
#[allow(dead_code)]
pub(crate) fn to_episode(&self) -> Result<Episode> {
self.index()?;
dbqueries::get_episode_from_pk(&self.title, self.podcast_id)
}
}
// Ignore the following getters. They are used in unit tests mainly.
impl NewEpisode {
pub(crate) fn title(&self) -> &str {
self.title.as_ref()
}
pub(crate) fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
pub(crate) fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str())
}
pub(crate) fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
pub(crate) fn epoch(&self) -> i32 {
self.epoch
}
pub(crate) fn duration(&self) -> Option<i32> {
self.duration
}
pub(crate) fn length(&self) -> Option<i32> {
self.length
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
}
}
#[derive(Insertable, AsChangeset)]
#[table_name = "episode"]
#[derive(Debug, Clone, Builder, PartialEq)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewEpisodeMinimal {
title: String,
uri: Option<String>,
duration: Option<i32>,
epoch: i32,
guid: Option<String>,
podcast_id: i32,
}
impl PartialEq<EpisodeMinimal> for NewEpisodeMinimal {
fn eq(&self, other: &EpisodeMinimal) -> bool {
(self.title() == other.title()) && (self.uri() == other.uri())
&& (self.duration() == other.duration()) && (self.epoch() == other.epoch())
&& (self.guid() == other.guid()) && (self.podcast_id() == other.podcast_id())
}
}
impl NewEpisodeMinimal {
pub(crate) fn new(item: &rss::Item, parent_id: i32) -> Result<Self> {
if item.title().is_none() {
bail!("No title specified for the item.")
}
let title = item.title().unwrap().trim().to_owned();
let guid = item.guid().map(|s| s.value().trim().to_owned());
let uri = if let Some(url) = item.enclosure().map(|s| url_cleaner(s.url())) {
Some(url)
} else if item.link().is_some() {
item.link().map(|s| url_cleaner(s))
} else {
bail!("No url specified for the item.")
};
// Default to rfc2822 represantation of epoch 0.
let date = parse_rfc822(item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"));
// Should treat information from the rss feeds as invalid by default.
// Case: Thu, 05 Aug 2016 06:00:00 -0400 <-- Actually that was friday.
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
let duration = parser::parse_itunes_duration(item.itunes_ext());
NewEpisodeMinimalBuilder::default()
.title(title)
.uri(uri)
.duration(duration)
.epoch(epoch)
.guid(guid)
.podcast_id(parent_id)
.build()
.map_err(From::from)
}
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
let length = || -> Option<i32> { item.enclosure().map(|x| x.length().parse().ok())? }();
// Prefer itunes summary over rss.description since many feeds put html into
// rss.description.
let summary = item.itunes_ext().map(|s| s.summary()).and_then(|s| s);
let description = if summary.is_some() {
summary.map(|s| replace_extra_spaces(&ammonia::clean(s)))
} else {
item.description()
.map(|s| replace_extra_spaces(&ammonia::clean(s)))
};
NewEpisodeBuilder::default()
.title(self.title)
.uri(self.uri)
.duration(self.duration)
.epoch(self.epoch)
.podcast_id(self.podcast_id)
.guid(self.guid)
.length(length)
.description(description)
.build()
.unwrap()
}
}
// Ignore the following getters. They are used in unit tests mainly.
impl NewEpisodeMinimal {
pub(crate) fn title(&self) -> &str {
self.title.as_ref()
}
pub(crate) fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
pub(crate) fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
pub(crate) fn duration(&self) -> Option<i32> {
self.duration
}
pub(crate) fn epoch(&self) -> i32 {
self.epoch
}
pub(crate) fn podcast_id(&self) -> i32 {
self.podcast_id
}
}
#[cfg(test)]
mod tests {
use database::truncate_db;
use dbqueries;
use models::*;
use models::new_episode::{NewEpisodeMinimal, NewEpisodeMinimalBuilder};
use rss::Channel;
use std::fs::File;
use std::io::BufReader;
// TODO: Add tests for other feeds too.
// Especially if you find an *intresting* generated feed.
// Known prebuilt expected objects.
lazy_static! {
static ref EXPECTED_MINIMAL_INTERCEPTED_1: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("The Super Bowl of Racism")
.uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3",
)))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.epoch(1505296800)
.duration(Some(4171))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_INTERCEPTED_2: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
.uri(Some(String::from(
"http://traffic.megaphone.fm/FL5331443769.mp3",
)))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.epoch(1502272800)
.duration(Some(4415))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_INTERCEPTED_1: NewEpisode = {
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data breach \
and allegations of Russian interference in the US election. Commentator \
Shaun King explains his call for a boycott of the NFL and talks about his \
campaign to bring violent neo-Nazis to justice. Rapper Open Mike Eagle \
performs.";
NewEpisodeBuilder::default()
.title("The Super Bowl of Racism")
.uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3",
)))
.description(Some(String::from(descr)))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(4171))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_INTERCEPTED_2: NewEpisode = {
let descr = "This week on Intercepted: Jeremy gives an update on the aftermath of \
Blackwaters 2007 massacre of Iraqi civilians. Intercept reporter Lee Fang \
lays out how a network of libertarian think tanks called the Atlas Network \
is insidiously shaping political infrastructure in Latin America. We speak \
with attorney and former Hugo Chavez adviser Eva Golinger about the \
Venezuela\'s political turmoil.And we hear Claudia Lizardo of the \
Caracas-based band, La Pequeña Revancha, talk about her music and hopes for \
Venezuela.";
NewEpisodeBuilder::default()
.title("Atlas Golfed — U.S.-Backed Think Tanks Target Latin America")
.uri(Some(String::from(
"http://traffic.megaphone.fm/FL5331443769.mp3",
)))
.description(Some(String::from(descr)))
.guid(Some(String::from("7c207a24-e33f-11e6-9438-eb45dcf36a1d")))
.length(Some(67527575))
.epoch(1502272800)
.duration(Some(4415))
.podcast_id(42)
.build()
.unwrap()
};
static ref UPDATED_DURATION_INTERCEPTED_1: NewEpisode = {
NewEpisodeBuilder::default()
.title("The Super Bowl of Racism")
.uri(Some(String::from(
"http://traffic.megaphone.fm/PPY6458293736.mp3",
)))
.description(Some(String::from("New description")))
.guid(Some(String::from("7df4070a-9832-11e7-adac-cb37b05d5e24")))
.length(Some(66738886))
.epoch(1505296800)
.duration(Some(424242))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_1: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Hacking Devices with Kali Linux | LUP 214")
.uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
)))
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_MINIMAL_LUP_2: NewEpisodeMinimal = {
NewEpisodeMinimalBuilder::default()
.title("Gnome Does it Again | LUP 213")
.uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3",
)))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.epoch(1504670247)
.duration(Some(4491))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_LUP_1: NewEpisode = {
let descr = "Audit your network with a couple of easy commands on Kali Linux. Chris \
decides to blow off a little steam by attacking his IoT devices, Wes has the \
scope on Equifax blaming open source &amp; the Beard just saved the show. \
Its a really packed episode!";
NewEpisodeBuilder::default()
.title("Hacking Devices with Kali Linux | LUP 214")
.uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3",
)))
.description(Some(String::from(descr)))
.guid(Some(String::from("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D")))
.length(Some(46479789))
.epoch(1505280282)
.duration(Some(5733))
.podcast_id(42)
.build()
.unwrap()
};
static ref EXPECTED_LUP_2: NewEpisode = {
let descr = "The Gnome project is about to solve one of our audience's biggest Waylands \
concerns. But as the project takes on a new level of relevance, decisions for the \
next version of Gnome have us worried about the future.\nPlus we chat with Wimpy \
about the Ubuntu Rally in NYC, Microsofts sneaky move to turn Windows 10 into the \
ULTIMATE LINUX RUNTIME, community news &amp; more!";
NewEpisodeBuilder::default()
.title("Gnome Does it Again | LUP 213")
.uri(Some(String::from(
"http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3",
)))
.description(Some(String::from(descr)))
.guid(Some(String::from("1CE57548-B36C-4F14-832A-5D5E0A24E35B")))
.length(Some(36544272))
.epoch(1504670247)
.duration(Some(4491))
.podcast_id(42)
.build()
.unwrap()
};
}
#[test]
fn test_new_episode_minimal_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_INTERCEPTED_2);
}
#[test]
fn test_new_episode_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let episode = channel.items().iter().nth(15).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
}
#[test]
fn test_new_episode_minimal_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_1);
let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisodeMinimal::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_MINIMAL_LUP_2);
}
#[test]
fn test_new_episode_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(18).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_LUP_1);
let episode = channel.items().iter().nth(19).unwrap();
let ep = NewEpisode::new(&episode, 42).unwrap();
assert_eq!(ep, *EXPECTED_LUP_2);
}
#[test]
fn test_minimal_into_new_episode() {
truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let item = channel.items().iter().nth(14).unwrap();
let ep = EXPECTED_MINIMAL_INTERCEPTED_1
.clone()
.into_new_episode(&item);
assert_eq!(ep, *EXPECTED_INTERCEPTED_1);
let item = channel.items().iter().nth(15).unwrap();
let ep = EXPECTED_MINIMAL_INTERCEPTED_2
.clone()
.into_new_episode(&item);
assert_eq!(ep, *EXPECTED_INTERCEPTED_2);
}
#[test]
fn test_new_episode_insert() {
truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let episode = channel.items().iter().nth(14).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_1);
assert_eq!(&*EXPECTED_INTERCEPTED_1, &ep);
let episode = channel.items().iter().nth(15).unwrap();
let new_ep = NewEpisode::new(&episode, 42).unwrap();
new_ep.insert().unwrap();
let ep = dbqueries::get_episode_from_pk(new_ep.title(), new_ep.podcast_id()).unwrap();
assert_eq!(new_ep, ep);
assert_eq!(&new_ep, &*EXPECTED_INTERCEPTED_2);
assert_eq!(&*EXPECTED_INTERCEPTED_2, &ep);
}
#[test]
fn test_new_episode_update() {
truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED_1.clone().to_episode().unwrap();
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
updated.update(old.rowid()).unwrap();
let mut new = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
// Assert that updating does not change the rowid and podcast_id
assert_ne!(old, new);
assert_eq!(old.rowid(), new.rowid());
assert_eq!(old.podcast_id(), new.podcast_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_episode_from_pk(old.title(), old.podcast_id()).unwrap();
assert_eq!(true, new2.archive());
}
#[test]
fn test_new_episode_index() {
truncate_db().unwrap();
let expected = &*EXPECTED_INTERCEPTED_1;
// First insert
assert!(expected.index().is_ok());
// Second identical, This should take the early return path
assert!(expected.index().is_ok());
// Get the episode
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
// Assert that NewPodcast is equal to the Indexed one
assert_eq!(*expected, old);
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
let new = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
// Assert it's diff from the old one.
assert_ne!(new, old);
assert_eq!(*updated, new);
assert_eq!(new.rowid(), old.rowid());
assert_eq!(new.podcast_id(), old.podcast_id());
}
#[test]
fn test_new_episode_to_episode() {
let expected = &*EXPECTED_INTERCEPTED_1;
let updated = &*UPDATED_DURATION_INTERCEPTED_1;
// Assert insert() produces the same result that you would get with to_podcast()
truncate_db().unwrap();
expected.insert().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
let ep = expected.to_episode().unwrap();
assert_eq!(old, ep);
// Same as above, diff order
truncate_db().unwrap();
let ep = expected.to_episode().unwrap();
// This should error as a unique constrain violation
assert!(expected.insert().is_err());
let mut old =
dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
assert_eq!(old, ep);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let ep = updated.to_episode().unwrap();
let old = dbqueries::get_episode_from_pk(expected.title(), expected.podcast_id()).unwrap();
assert_eq!(old, ep);
assert_eq!(old.archive(), true);
}
}

View File

@ -0,0 +1,421 @@
use diesel;
use diesel::prelude::*;
use ammonia;
use rss;
use models::{Index, Insert, Update};
use models::Podcast;
use schema::podcast;
use database::connection;
use dbqueries;
use utils::{replace_extra_spaces, url_cleaner};
use errors::*;
#[derive(Insertable, AsChangeset)]
#[table_name = "podcast"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewPodcast {
title: String,
link: String,
description: String,
image_uri: Option<String>,
source_id: i32,
}
impl Insert for NewPodcast {
fn insert(&self) -> Result<()> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_into(podcast)
.values(self)
.execute(&con)
.map(|_| ())
.map_err(From::from)
}
}
impl Update for NewPodcast {
fn update(&self, podcast_id: i32) -> Result<()> {
use schema::podcast::dsl::*;
let db = connection();
let con = db.get()?;
info!("Updating {}", self.title);
diesel::update(podcast.filter(id.eq(podcast_id)))
.set(self)
.execute(&con)
.map(|_| ())
.map_err(From::from)
}
}
// TODO: Maybe return an Enum<Action(Resut)> Instead.
// It would make unti testing better too.
impl Index for NewPodcast {
fn index(&self) -> Result<()> {
let exists = dbqueries::podcast_exists(self.source_id)?;
if exists {
let other = dbqueries::get_podcast_from_source_id(self.source_id)?;
if self != &other {
self.update(other.id())
} else {
Ok(())
}
} else {
self.insert()
}
}
}
impl PartialEq<Podcast> for NewPodcast {
fn eq(&self, other: &Podcast) -> bool {
(self.link() == other.link()) && (self.title() == other.title())
&& (self.image_uri() == other.image_uri())
&& (self.description() == other.description())
&& (self.source_id() == other.source_id())
}
}
impl NewPodcast {
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
let title = chan.title().trim();
// Prefer itunes summary over rss.description since many feeds put html into
// rss.description.
let summary = chan.itunes_ext().map(|s| s.summary()).and_then(|s| s);
let description = if let Some(sum) = summary {
replace_extra_spaces(&ammonia::clean(sum))
} else {
replace_extra_spaces(&ammonia::clean(chan.description()))
};
let link = url_cleaner(chan.link());
let x = chan.itunes_ext().map(|s| s.image());
let image_uri = if let Some(img) = x {
img.map(|s| s.to_owned())
} else {
chan.image().map(|foo| foo.url().to_owned())
};
NewPodcastBuilder::default()
.title(title)
.description(description)
.link(link)
.image_uri(image_uri)
.source_id(source_id)
.build()
.unwrap()
}
// Look out for when tryinto lands into stable.
pub(crate) fn to_podcast(&self) -> Result<Podcast> {
self.index()?;
dbqueries::get_podcast_from_source_id(self.source_id).map_err(From::from)
}
}
// Ignore the following geters. They are used in unit tests mainly.
impl NewPodcast {
#[allow(dead_code)]
pub(crate) fn source_id(&self) -> i32 {
self.source_id
}
pub(crate) fn title(&self) -> &str {
&self.title
}
pub(crate) fn link(&self) -> &str {
&self.link
}
pub(crate) fn description(&self) -> &str {
&self.description
}
pub(crate) fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
// use tokio_core::reactor::Core;
use rss::Channel;
use database::truncate_db;
use models::{NewPodcastBuilder, Save};
use std::fs::File;
use std::io::BufReader;
// Pre-built expected NewPodcast structs.
lazy_static!{
static ref EXPECTED_INTERCEPTED: NewPodcast = {
let descr = "The people behind The Intercepts fearless reporting and incisive \
commentaryJeremy Scahill, Glenn Greenwald, Betsy Reed and othersdiscuss \
the crucial issues of our time: national security, civil liberties, foreign \
policy, and criminal justice. Plus interviews with artists, thinkers, and \
newsmakers who challenge our preconceptions about the world we live in.";
NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description(descr)
.image_uri(Some(String::from(
"http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/\
uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_\
2FIntercepted_COVER%2B_281_29.png")
))
.source_id(42)
.build()
.unwrap()
};
static ref EXPECTED_LUP: NewPodcast = {
let descr = "An open show powered by community LINUX Unplugged takes the best attributes \
of open collaboration and focuses them into a weekly lifestyle show about \
Linux.";
NewPodcastBuilder::default()
.title("LINUX Unplugged Podcast")
.link("http://www.jupiterbroadcasting.com/")
.description(descr)
.image_uri(Some(String::from(
"http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg",
)))
.source_id(42)
.build()
.unwrap()
};
static ref EXPECTED_TIPOFF: NewPodcast = {
let desc = "Welcome to The Tip Off- the podcast where we take you behind the scenes of \
some of the best investigative journalism from recent years. Each episode \
well be digging into an investigative scoop- hearing from the journalists \
behind the work as they tell us about the leads, the dead-ends and of course, \
the tip offs. Therell be car chases, slammed doors, terrorist cells, \
meetings in dimly lit bars and cafes, wrangling with despotic regimes and \
much more. So if youre curious about the fun, complicated detective work \
that goes into doing great investigative journalism- then this is the podcast \
for you.";
NewPodcastBuilder::default()
.title("The Tip Off")
.link("http://www.acast.com/thetipoff")
.description(desc)
.image_uri(Some(String::from(
"https://imagecdn.acast.com/image?h=1500&w=1500&source=http%3A%2F%2Fi1.sndcdn.\
com%2Favatars-000317856075-a2coqz-original.jpg",
)))
.source_id(42)
.build()
.unwrap()
};
static ref EXPECTED_STARS: NewPodcast = {
let descr = "<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars is \
a gripping noir science fiction thriller in 14 episodes: Forbidden love, a \
crashed UFO, an alien body, and an impossible heist unlike any ever \
attempted - scripted by Mac Rogers, the award-winning playwright and writer \
of the multi-million download The Message and LifeAfter.</p>";
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
NewPodcastBuilder::default()
.title("Steal the Stars")
.link("http://tor-labs.com/")
.description(descr)
.image_uri(Some(String::from(img)))
.source_id(42)
.build()
.unwrap()
};
static ref EXPECTED_CODE: NewPodcast = {
let descr = "A podcast about humans and technology. Panelists: Coraline Ada Ehmke, David \
Brady, Jessica Kerr, Jay Bobo, Astrid Countee and Sam Livingston-Gray. \
Brought to you by @therubyrep.";
NewPodcastBuilder::default()
.title("Greater Than Code")
.link("https://www.greaterthancode.com/")
.description(descr)
.image_uri(Some(String::from(
"http://www.greaterthancode.com/wp-content/uploads/2016/10/code1400-4.jpg",
)))
.source_id(42)
.build()
.unwrap()
};
static ref UPDATED_DESC_INTERCEPTED: NewPodcast = {
NewPodcastBuilder::default()
.title("Intercepted with Jeremy Scahill")
.link("https://theintercept.com/podcasts")
.description("New Description")
.image_uri(Some(String::from(
"http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/\
uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_\
2FIntercepted_COVER%2B_281_29.png")
))
.source_id(42)
.build()
.unwrap()
};
}
#[test]
fn test_new_podcast_intercepted() {
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_INTERCEPTED, pd);
}
#[test]
fn test_new_podcast_lup() {
let file = File::open("tests/feeds/2018-01-20-LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_LUP, pd);
}
#[test]
fn test_new_podcast_thetipoff() {
let file = File::open("tests/feeds/2018-01-20-TheTipOff.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_TIPOFF, pd);
}
#[test]
fn test_new_podcast_steal_the_stars() {
let file = File::open("tests/feeds/2018-01-20-StealTheStars.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_STARS, pd);
}
#[test]
fn test_new_podcast_greater_than_code() {
let file = File::open("tests/feeds/2018-01-20-GreaterThanCode.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = NewPodcast::new(&channel, 42);
assert_eq!(*EXPECTED_CODE, pd);
}
#[test]
// This maybe could be a doc test on insert.
fn test_new_podcast_insert() {
truncate_db().unwrap();
let file = File::open("tests/feeds/2018-01-20-Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let npd = NewPodcast::new(&channel, 42);
npd.insert().unwrap();
let pd = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(npd, pd);
assert_eq!(*EXPECTED_INTERCEPTED, npd);
assert_eq!(&*EXPECTED_INTERCEPTED, &pd);
}
#[test]
// TODO: Add more test/checks
// Currently there's a test that only checks new description or title.
// If you have time and want to help, implement the test for the other fields too.
fn test_new_podcast_update() {
truncate_db().unwrap();
let old = EXPECTED_INTERCEPTED.to_podcast().unwrap();
let updated = &*UPDATED_DESC_INTERCEPTED;
updated.update(old.id()).unwrap();
let mut new = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_ne!(old, new);
assert_eq!(old.id(), new.id());
assert_eq!(old.source_id(), new.source_id());
assert_eq!(updated, &new);
assert_ne!(updated, &old);
// Chech that the update does not override user preferences.
new.set_archive(true);
new.save().unwrap();
let new2 = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(true, new2.archive());
}
#[test]
fn test_new_podcast_index() {
truncate_db().unwrap();
// First insert
assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Second identical, This should take the early return path
assert!(EXPECTED_INTERCEPTED.index().is_ok());
// Get the podcast
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert that NewPodcast is equal to the Indexed one
assert_eq!(&*EXPECTED_INTERCEPTED, &old);
let updated = &*UPDATED_DESC_INTERCEPTED;
// Update the podcast
assert!(updated.index().is_ok());
// Get the new Podcast
let new = dbqueries::get_podcast_from_source_id(42).unwrap();
// Assert it's diff from the old one.
assert_ne!(new, old);
assert_eq!(new.id(), old.id());
assert_eq!(new.source_id(), old.source_id());
}
#[test]
fn test_to_podcast() {
// Assert insert() produces the same result that you would get with to_podcast()
truncate_db().unwrap();
EXPECTED_INTERCEPTED.insert().unwrap();
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
assert_eq!(old, pd);
// Same as above, diff order
truncate_db().unwrap();
let pd = EXPECTED_INTERCEPTED.to_podcast().unwrap();
// This should error as a unique constrain violation
assert!(EXPECTED_INTERCEPTED.insert().is_err());
let mut old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd);
old.set_archive(true);
old.save().unwrap();
// Assert that it does not mess with user preferences
let pd = UPDATED_DESC_INTERCEPTED.to_podcast().unwrap();
let old = dbqueries::get_podcast_from_source_id(42).unwrap();
assert_eq!(old, pd);
assert_eq!(old.archive(), true);
}
}

View File

@ -0,0 +1,53 @@
#![allow(unused_mut)]
use diesel;
use diesel::prelude::*;
use url::Url;
use database::connection;
use dbqueries;
// use models::{Insert, Update};
use models::Source;
use schema::source;
use errors::*;
#[derive(Insertable)]
#[table_name = "source"]
#[derive(Debug, Clone, Default, Builder, PartialEq)]
#[builder(default)]
#[builder(derive(Debug))]
#[builder(setter(into))]
pub(crate) struct NewSource {
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl NewSource {
pub(crate) fn new(uri: &Url) -> NewSource {
NewSource {
uri: uri.to_string(),
last_modified: None,
http_etag: None,
}
}
pub(crate) fn insert_or_ignore(&self) -> Result<()> {
use schema::source::dsl::*;
let db = connection();
let con = db.get()?;
diesel::insert_or_ignore_into(source)
.values(self)
.execute(&con)
.map(|_| ())
.map_err(From::from)
}
// Look out for when tryinto lands into stable.
pub(crate) fn to_source(&self) -> Result<Source> {
self.insert_or_ignore()?;
dbqueries::get_source_from_uri(&self.uri)
}
}

View File

@ -0,0 +1,158 @@
use diesel::SaveChangesDsl;
use database::connection;
use errors::*;
use models::{Save, Source};
use schema::podcast;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "podcast"]
#[derive(Debug, Clone)]
/// Diesel Model of the podcast table.
pub struct Podcast {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
favorite: bool,
archive: bool,
always_dl: bool,
source_id: i32,
}
impl Save<Podcast> for Podcast {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<Podcast> {
let db = connection();
let tempdb = db.get()?;
self.save_changes::<Podcast>(&*tempdb).map_err(From::from)
}
}
impl Podcast {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the Feed `link`.
///
/// Usually the website/homepage of the content creator.
pub fn link(&self) -> &str {
&self.link
}
/// Set the Podcast/Feed `link`.
pub fn set_link(&mut self, value: &str) {
self.link = value.to_string();
}
/// Get the `description`.
pub fn description(&self) -> &str {
&self.description
}
/// Set the `description`.
pub fn set_description(&mut self, value: &str) {
self.description = value.to_string();
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
/// Set the `image_uri`.
pub fn set_image_uri(&mut self, value: Option<&str>) {
self.image_uri = value.map(|x| x.to_string());
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Podcast` Feed.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// Represents the download policy for the `Podcast` Feed.
///
/// Reserved for the use with a Download manager, yet to be implemented.
///
/// If true Podcast Episode should be downloaded automaticly/skipping
/// the selection queue.
pub fn always_download(&self) -> bool {
self.always_dl
}
/// Set the download policy.
pub fn set_always_download(&mut self, b: bool) {
self.always_dl = b
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
}
}
#[derive(Queryable, Debug, Clone)]
/// Diesel Model of the podcast cover query.
/// Used for fetching information about a Podcast's cover.
pub struct PodcastCoverQuery {
id: i32,
title: String,
image_uri: Option<String>,
}
impl From<Podcast> for PodcastCoverQuery {
fn from(p: Podcast) -> PodcastCoverQuery {
PodcastCoverQuery {
id: p.id(),
title: p.title,
image_uri: p.image_uri,
}
}
}
impl PodcastCoverQuery {
/// Get the Feed `id`.
pub fn id(&self) -> i32 {
self.id
}
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
}

View File

@ -1,428 +0,0 @@
use chrono::prelude::*;
use reqwest;
use diesel::SaveChangesDsl;
use reqwest::header::{ETag, LastModified};
use rss::Channel;
use schema::{episode, podcast, source};
use feed::Feed;
use errors::*;
use models::insertables::NewSource;
use database::connection;
use std::io::Read;
use std::str::FromStr;
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[table_name = "episode"]
#[changeset_options(treat_none_as_null = "true")]
#[primary_key(title, podcast_id)]
#[belongs_to(Podcast, foreign_key = "podcast_id")]
#[derive(Debug, Clone)]
/// Diesel Model of the episode table.
pub struct Episode {
rowid: i32,
title: String,
uri: Option<String>,
local_uri: Option<String>,
description: Option<String>,
published_date: Option<String>,
epoch: i32,
length: Option<i32>,
guid: Option<String>,
played: Option<i32>,
favorite: bool,
archive: bool,
podcast_id: i32,
}
impl Episode {
/// Get the value of the sqlite's `ROW_ID`
pub fn rowid(&self) -> i32 {
self.rowid
}
/// Get the value of the `title` field.
pub fn title(&self) -> &str {
&self.title
}
/// Set the `title`.
pub fn set_title(&mut self, value: &str) {
self.title = value.to_string();
}
/// Get the value of the `uri`.
///
/// Represents the url(usually) that the media file will be located at.
pub fn uri(&self) -> Option<&str> {
self.uri.as_ref().map(|s| s.as_str())
}
/// Set the `uri`.
pub fn set_uri(&mut self, value: Option<&str>) {
self.uri = value.map(|x| x.to_string());
}
/// Get the value of the `local_uri`.
///
/// Represents the local uri,usually filesystem path,
/// that the media file will be located at.
pub fn local_uri(&self) -> Option<&str> {
self.local_uri.as_ref().map(|s| s.as_str())
}
/// Set the `local_uri`.
pub fn set_local_uri(&mut self, value: Option<&str>) {
self.local_uri = value.map(|x| x.to_string());
}
/// Get the `description`.
pub fn description(&self) -> Option<&str> {
self.description.as_ref().map(|s| s.as_str())
}
/// Set the `description`.
pub fn set_description(&mut self, value: Option<&str>) {
self.description = value.map(|x| x.to_string());
}
/// Get the the `published_date`.
pub fn published_date(&self) -> Option<&str> {
self.published_date.as_ref().map(|s| s.as_str())
}
/// Set the `published_date`.
pub fn set_published_date(&mut self, value: Option<&str>) {
self.published_date = value.map(|x| x.to_string().to_owned());
}
/// Get the value of the `description`.
pub fn guid(&self) -> Option<&str> {
self.guid.as_ref().map(|s| s.as_str())
}
/// Set the `guid`.
pub fn set_guid(&mut self, value: Option<&str>) {
self.guid = value.map(|x| x.to_string());
}
/// Get the `epoch` value.
///
/// Retrieved from the rss Item publish date.
/// Value is set to Utc whenever possible.
pub fn epoch(&self) -> i32 {
self.epoch
}
/// Set the `epoch`.
pub fn set_epoch(&mut self, value: i32) {
self.epoch = value;
}
/// Get the `length`.
pub fn length(&self) -> Option<i32> {
self.length
}
/// Set the `length`.
pub fn set_length(&mut self, value: Option<i32>) {
self.length = value;
}
/// Epoch representation of the last time the episode was played.
///
/// None/Null for unplayed.
pub fn played(&self) -> Option<i32> {
self.played
}
/// Set the `played` value.
pub fn set_played(&mut self, value: Option<i32>) {
self.played = value;
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
///
/// If true, the download cleanr will ignore the episode
/// and the corresponding media value will never be automaticly deleted.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Episode`.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// `Podcast` table foreign key.
pub fn podcast_id(&self) -> i32 {
self.podcast_id
}
/// Sets the `played` value with the current `epoch` timestap and save it.
pub fn set_played_now(&mut self) -> Result<()> {
let epoch = Utc::now().timestamp() as i32;
self.set_played(Some(epoch));
self.save()?;
Ok(())
}
/// Helper method to easily save/"sync" current state of self to the Database.
pub fn save(&self) -> Result<Episode> {
let db = connection();
let tempdb = db.get()?;
Ok(self.save_changes::<Episode>(&*tempdb)?)
}
}
#[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)]
#[belongs_to(Source, foreign_key = "source_id")]
#[changeset_options(treat_none_as_null = "true")]
#[table_name = "podcast"]
#[derive(Debug, Clone)]
/// Diesel Model of the podcast table.
pub struct Podcast {
id: i32,
title: String,
link: String,
description: String,
image_uri: Option<String>,
favorite: bool,
archive: bool,
always_dl: bool,
source_id: i32,
}
impl Podcast {
/// Get the Feed `title`.
pub fn title(&self) -> &str {
&self.title
}
/// Get the Feed `link`.
///
/// Usually the website/homepage of the content creator.
pub fn link(&self) -> &str {
&self.link
}
/// Set the Podcast/Feed `link`.
pub fn set_link(&mut self, value: &str) {
self.link = value.to_string();
}
/// Get the `description`.
pub fn description(&self) -> &str {
&self.description
}
/// Set the `description`.
pub fn set_description(&mut self, value: &str) {
self.description = value.to_string();
}
/// Get the `image_uri`.
///
/// Represents the uri(url usually) that the Feed cover image is located at.
pub fn image_uri(&self) -> Option<&str> {
self.image_uri.as_ref().map(|s| s.as_str())
}
/// Set the `image_uri`.
pub fn set_image_uri(&mut self, value: Option<&str>) {
self.image_uri = value.map(|x| x.to_string());
}
/// Represents the archiving policy for the episode.
pub fn archive(&self) -> bool {
self.archive
}
/// Set the `archive` policy.
pub fn set_archive(&mut self, b: bool) {
self.archive = b
}
/// Get the `favorite` status of the `Podcast` Feed.
pub fn favorite(&self) -> bool {
self.favorite
}
/// Set `favorite` status.
pub fn set_favorite(&mut self, b: bool) {
self.favorite = b
}
/// Represents the download policy for the `Podcast` Feed.
///
/// Reserved for the use with a Download manager, yet to be implemented.
///
/// If true Podcast Episode should be downloaded automaticly/skipping
/// the selection queue.
pub fn always_download(&self) -> bool {
self.always_dl
}
/// Set the download policy.
pub fn set_always_download(&mut self, b: bool) {
self.always_dl = b
}
/// `Source` table foreign key.
pub fn source_id(&self) -> i32 {
self.source_id
}
/// Helper method to easily save/"sync" current state of self to the Database.
pub fn save(&self) -> Result<Podcast> {
let db = connection();
let tempdb = db.get()?;
Ok(self.save_changes::<Podcast>(&*tempdb)?)
}
}
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
#[table_name = "source"]
#[changeset_options(treat_none_as_null = "true")]
#[derive(Debug, Clone)]
/// Diesel Model of the source table.
pub struct Source {
id: i32,
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl<'a> Source {
/// Represents the location(usually url) of the Feed xml file.
pub fn uri(&self) -> &str {
&self.uri
}
/// Represents the Http Last-Modified Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn last_modified(&self) -> Option<&str> {
self.last_modified.as_ref().map(|s| s.as_str())
}
/// Set `last_modified` value.
pub fn set_last_modified(&mut self, value: Option<&str>) {
self.last_modified = value.map(|x| x.to_string());
}
/// Represents the Http Etag Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn http_etag(&self) -> Option<&str> {
self.http_etag.as_ref().map(|s| s.as_str())
}
/// Set `http_etag` value.
pub fn set_http_etag(&mut self, value: Option<&str>) {
self.http_etag = value.map(|x| x.to_string());
}
/// Extract Etag and LastModifier from req, and update self and the
/// corresponding db row.
fn update_etag(&mut self, req: &reqwest::Response) -> Result<()> {
let headers = req.headers();
// let etag = headers.get_raw("ETag").unwrap();
let etag = headers.get::<ETag>();
let lmod = headers.get::<LastModified>();
// FIXME: This dsnt work most of the time apparently
if self.http_etag() != etag.map(|x| x.tag()) || self.last_modified != lmod.map(|x| {
format!("{}", x)
}) {
self.http_etag = etag.map(|x| x.tag().to_string().to_owned());
self.last_modified = lmod.map(|x| format!("{}", x));
self.save()?;
}
Ok(())
}
/// Helper method to easily save/"sync" current state of self to the Database.
pub fn save(&self) -> Result<Source> {
let db = connection();
let tempdb = db.get()?;
Ok(self.save_changes::<Source>(&*tempdb)?)
}
/// `Feed` constructor.
///
/// Fetches the latest xml Feed.
///
/// Updates the validator Http Headers.
///
/// Consumes `self` and Returns the corresponding `Feed` Object.
// TODO: Refactor into TryInto once it lands on stable.
pub fn into_feed(mut self) -> Result<Feed> {
use reqwest::header::{EntityTag, Headers, HttpDate, IfNoneMatch, IfModifiedSince};
let mut headers = Headers::new();
if let Some(foo) = self.http_etag() {
headers.set(
IfNoneMatch::Items(vec![
EntityTag::new(true, foo.to_owned())
])
);
}
if let Some(foo) = self.last_modified() {
if let Ok(x) = foo.parse::<HttpDate>() {
headers.set(IfModifiedSince(x));
}
}
// FIXME: I have fucked up somewhere here.
// Getting back 200 codes even though I supposedly sent etags.
// info!("Headers: {:?}", headers);
let client = reqwest::Client::builder().referer(false).build()?;
let mut req = client.get(self.uri()).headers(headers).send()?;
info!("GET to {} , returned: {}", self.uri(), req.status());
// TODO match on more stuff
// 301: Permanent redirect of the url
// 302: Temporary redirect of the url
// 304: Up to date Feed, checked with the Etag
// 410: Feed deleted
// match req.status() {
// reqwest::StatusCode::NotModified => (),
// _ => (),
// };
self.update_etag(&req)?;
let mut buf = String::new();
req.read_to_string(&mut buf)?;
let chan = Channel::from_str(&buf)?;
Ok(Feed::from_channel_source(chan, self))
}
/// Construct a new `Source` with the given `uri` and index it.
pub fn from_url(uri: &str) -> Result<Source> {
NewSource::new_with_uri(uri).into_source()
}
}

View File

@ -0,0 +1,265 @@
use diesel::SaveChangesDsl;
use rss::Channel;
use url::Url;
use hyper::{Client, Method, Request, Response, StatusCode, Uri};
use hyper::client::HttpConnector;
use hyper::header::{ETag, EntityTag, HttpDate, IfModifiedSince, IfNoneMatch, LastModified,
Location};
use hyper_tls::HttpsConnector;
// use futures::future::ok;
use futures::prelude::*;
use futures_cpupool::CpuPool;
use database::connection;
use errors::*;
use feed::{Feed, FeedBuilder};
use models::{NewSource, Save};
use schema::source;
use std::str::FromStr;
#[derive(Queryable, Identifiable, AsChangeset, PartialEq)]
#[table_name = "source"]
#[changeset_options(treat_none_as_null = "true")]
#[derive(Debug, Clone)]
/// Diesel Model of the source table.
pub struct Source {
id: i32,
uri: String,
last_modified: Option<String>,
http_etag: Option<String>,
}
impl Save<Source> for Source {
/// Helper method to easily save/"sync" current state of self to the Database.
fn save(&self) -> Result<Source> {
let db = connection();
let con = db.get()?;
self.save_changes::<Source>(&con).map_err(From::from)
}
}
impl Source {
/// Get the source `id` column.
pub fn id(&self) -> i32 {
self.id
}
/// Represents the location(usually url) of the Feed xml file.
pub fn uri(&self) -> &str {
&self.uri
}
/// Set the `uri` field value.
pub fn set_uri(&mut self, uri: String) {
self.uri = uri;
}
/// Represents the Http Last-Modified Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn last_modified(&self) -> Option<&str> {
self.last_modified.as_ref().map(|s| s.as_str())
}
/// Set `last_modified` value.
pub fn set_last_modified(&mut self, value: Option<String>) {
// self.last_modified = value.map(|x| x.to_string());
self.last_modified = value;
}
/// Represents the Http Etag Header field.
///
/// See [RFC 7231](https://tools.ietf.org/html/rfc7231#section-7.2) for more.
pub fn http_etag(&self) -> Option<&str> {
self.http_etag.as_ref().map(|s| s.as_str())
}
/// Set `http_etag` value.
pub fn set_http_etag(&mut self, value: Option<&str>) {
self.http_etag = value.map(|x| x.to_string());
}
/// Extract Etag and LastModifier from res, and update self and the
/// corresponding db row.
fn update_etag(&mut self, res: &Response) -> Result<()> {
let headers = res.headers();
let etag = headers.get::<ETag>().map(|x| x.tag());
let lmod = headers.get::<LastModified>().map(|x| format!("{}", x));
if (self.http_etag() != etag) || (self.last_modified != lmod) {
self.set_http_etag(etag);
self.set_last_modified(lmod);
self.save()?;
}
Ok(())
}
// TODO match on more stuff
// 301: Moved Permanently
// 304: Up to date Feed, checked with the Etag
// 307: Temporary redirect of the url
// 308: Permanent redirect of the url
// 401: Unathorized
// 403: Forbidden
// 408: Timeout
// 410: Feed deleted
fn match_status(mut self, res: Response) -> Result<(Self, Response)> {
let code = res.status();
match code {
StatusCode::NotModified => bail!("304: skipping.."),
StatusCode::MovedPermanently => {
error!("Feed was moved permanently.");
self.handle_301(&res)?;
bail!("301: Feed was moved permanently.")
}
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
StatusCode::Unauthorized => bail!("401: Unauthorized."),
StatusCode::Forbidden => bail!("403: Forbidden."),
StatusCode::NotFound => bail!("404: Not found."),
StatusCode::RequestTimeout => bail!("408: Request Timeout."),
StatusCode::Gone => bail!("410: Feed was deleted."),
_ => info!("HTTP StatusCode: {}", code),
};
Ok((self, res))
}
fn handle_301(&mut self, res: &Response) -> Result<()> {
let headers = res.headers();
if let Some(url) = headers.get::<Location>() {
self.set_uri(url.to_string());
self.save()?;
info!("Feed url was updated succesfully.");
// TODO: Refresh in place instead of next time, Not a priority.
info!("New content will be fetched with the next refesh.");
}
Ok(())
}
/// Construct a new `Source` with the given `uri` and index it.
///
/// This only indexes the `Source` struct, not the Podcast Feed.
pub fn from_url(uri: &str) -> Result<Source> {
let url = Url::parse(uri)?;
NewSource::new(&url).to_source()
}
/// `Feed` constructor.
///
/// Fetches the latest xml Feed.
///
/// Updates the validator Http Headers.
///
/// Consumes `self` and Returns the corresponding `Feed` Object.
// TODO: Refactor into TryInto once it lands on stable.
pub fn into_feed(
self,
client: &Client<HttpsConnector<HttpConnector>>,
pool: CpuPool,
ignore_etags: bool,
) -> Box<Future<Item = Feed, Error = Error>> {
let id = self.id();
let feed = self.request_constructor(client, ignore_etags)
.and_then(move |(mut source, res)| {
source.update_etag(&res)?;
Ok(res)
})
.and_then(move |res| response_to_channel(res, pool))
.and_then(move |chan| {
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.map_err(From::from)
});
Box::new(feed)
}
// TODO: make ignore_etags an Enum for better ergonomics.
// #bools_are_just_2variant_enmus
fn request_constructor(
self,
client: &Client<HttpsConnector<HttpConnector>>,
ignore_etags: bool,
) -> Box<Future<Item = (Self, Response), Error = Error>> {
// FIXME: remove unwrap somehow
let uri = Uri::from_str(self.uri()).unwrap();
let mut req = Request::new(Method::Get, uri);
if !ignore_etags {
if let Some(foo) = self.http_etag() {
req.headers_mut().set(IfNoneMatch::Items(vec![
EntityTag::new(true, foo.to_owned()),
]));
}
if let Some(foo) = self.last_modified() {
if let Ok(x) = foo.parse::<HttpDate>() {
req.headers_mut().set(IfModifiedSince(x));
}
}
}
let work = client
.request(req)
.map_err(From::from)
.and_then(move |res| self.match_status(res));
Box::new(work)
}
}
fn response_to_channel(
res: Response,
pool: CpuPool,
) -> Box<Future<Item = Channel, Error = Error> + Send> {
let chan = res.body()
.concat2()
.map(|x| x.into_iter())
.map_err(From::from)
.map(|iter| iter.collect::<Vec<u8>>())
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
.and_then(|buf| Channel::from_str(&buf).map_err(From::from));
let cpu_chan = pool.spawn(chan);
Box::new(cpu_chan)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio_core::reactor::Core;
use database::truncate_db;
use utils::get_feed;
#[test]
fn test_into_feed() {
truncate_db().unwrap();
let pool = CpuPool::new_num_cpus();
let mut core = Core::new().unwrap();
let client = Client::configure()
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
.build(&core.handle());
let url = "https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill";
let source = Source::from_url(url).unwrap();
let id = source.id();
let feed = source.into_feed(&client, pool.clone(), true);
let feed = core.run(feed).unwrap();
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
assert_eq!(expected, feed);
}
}

View File

@ -1,378 +1,81 @@
use ammonia;
use rss::{Channel, Item};
use rfc822_sanitizer::parse_from_rfc2822_with_fallback;
use rss::extension::itunes::ITunesItemExtension;
use models::insertables::{NewEpisode, NewEpisodeBuilder, NewPodcast, NewPodcastBuilder};
use utils::url_cleaner;
use utils::replace_extra_spaces;
/// Parses an Item Itunes extension and returns it's duration value in seconds.
// FIXME: Rafactor
#[allow(non_snake_case)]
pub(crate) fn parse_itunes_duration(item: Option<&ITunesItemExtension>) -> Option<i32> {
let duration = item.map(|s| s.duration())??;
use errors::*;
// TODO: Extend the support for parsing itunes extensions
/// Parses a `rss::Channel` into a `NewPodcast` Struct.
pub(crate) fn new_podcast(chan: &Channel, source_id: i32) -> NewPodcast {
let title = chan.title().trim();
let description = replace_extra_spaces(&ammonia::clean(chan.description()));
let link = url_cleaner(chan.link());
let x = chan.itunes_ext().map(|s| s.image());
let image_uri = if let Some(img) = x {
img.map(|s| url_cleaner(s))
} else {
chan.image().map(|foo| url_cleaner(foo.url()))
// FOR SOME FUCKING REASON, IN THE APPLE EXTENSION SPEC
// THE DURATION CAN BE EITHER AN INT OF SECONDS OR
// A STRING OF THE FOLLOWING FORMATS:
// HH:MM:SS, H:MM:SS, MM:SS, M:SS
// LIKE WHO THE FUCK THOUGH THAT WOULD BE A GOOD IDEA.
if let Ok(NO_FUCKING_LOGIC) = duration.parse::<i32>() {
return Some(NO_FUCKING_LOGIC);
};
NewPodcastBuilder::default()
.title(title)
.description(description)
.link(link)
.image_uri(image_uri)
.source_id(source_id)
.build()
.unwrap()
}
/// Parses an `rss::Item` into a `NewEpisode` Struct.
pub(crate) fn new_episode(item: &Item, parent_id: i32) -> Result<NewEpisode> {
if item.title().is_none() {
bail!("No title specified for the item.")
let mut seconds = 0;
let fk_apple = duration.split(':').collect::<Vec<_>>();
if fk_apple.len() == 3 {
seconds += fk_apple[0].parse::<i32>().unwrap_or(0) * 3600;
seconds += fk_apple[1].parse::<i32>().unwrap_or(0) * 60;
seconds += fk_apple[2].parse::<i32>().unwrap_or(0);
} else if fk_apple.len() == 2 {
seconds += fk_apple[0].parse::<i32>().unwrap_or(0) * 60;
seconds += fk_apple[1].parse::<i32>().unwrap_or(0);
}
let title = item.title().unwrap().trim().to_owned();
let description = item.description()
.map(|s| replace_extra_spaces(&ammonia::clean(s)));
let guid = item.guid().map(|s| s.value().trim().to_owned());
// Its kinda weird this being an Option type.
// Rss 2.0 specified that it's optional.
// Though the db scema has a requirment of episode uri being Unique && Not Null.
// TODO: Restructure
let x = item.enclosure().map(|s| url_cleaner(s.url()));
// FIXME: refactor
let uri = if x.is_some() {
x
} else if item.link().is_some() {
item.link().map(|s| url_cleaner(s))
} else {
bail!("No url specified for the item.")
};
let date = parse_from_rfc2822_with_fallback(
// Default to rfc2822 represantation of epoch 0.
item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"),
);
// Should treat information from the rss feeds as invalid by default.
// Case: Thu, 05 Aug 2016 06:00:00 -0400 <-- Actually that was friday.
let pub_date = date.map(|x| x.to_rfc2822()).ok();
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
let length = item.enclosure().map(|x| x.length().parse().unwrap_or(0));
Ok(NewEpisodeBuilder::default()
.title(title)
.uri(uri)
.description(description)
.length(length)
.published_date(pub_date)
.epoch(epoch)
.guid(guid)
.podcast_id(parent_id)
.build()
.unwrap())
Some(seconds)
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::io::BufReader;
use rss::Channel;
use rss::extension::itunes::ITunesItemExtensionBuilder;
use super::*;
#[test]
fn test_new_podcast_intercepted() {
let file = File::open("tests/feeds/Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
fn test_itunes_duration() {
// Input is a String<Int>
let extension = ITunesItemExtensionBuilder::default()
.duration(Some("3370".into()))
.build()
.unwrap();
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(3370));
let descr = "The people behind The Intercepts fearless reporting and incisive \
commentaryJeremy Scahill, Glenn Greenwald, Betsy Reed and othersdiscuss \
the crucial issues of our time: national security, civil liberties, foreign \
policy, and criminal justice. Plus interviews with artists, thinkers, and \
newsmakers who challenge our preconceptions about the world we live in.";
let pd = new_podcast(&channel, 0);
// Input is a String<M:SS>
let extension = ITunesItemExtensionBuilder::default()
.duration(Some("6:10".into()))
.build()
.unwrap();
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(370));
assert_eq!(pd.title(), "Intercepted with Jeremy Scahill");
assert_eq!(pd.link(), "https://theintercept.com/podcasts");
assert_eq!(pd.description(), descr);
assert_eq!(
pd.image_uri(),
Some(
"http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/\
uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_\
2FIntercepted_COVER%2B_281_29.png"
)
);
// Input is a String<MM:SS>
let extension = ITunesItemExtensionBuilder::default()
.duration(Some("56:10".into()))
.build()
.unwrap();
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(3370));
// Input is a String<H:MM:SS>
let extension = ITunesItemExtensionBuilder::default()
.duration(Some("1:56:10".into()))
.build()
.unwrap();
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(6970));
// Input is a String<HH:MM:SS>
let extension = ITunesItemExtensionBuilder::default()
.duration(Some("01:56:10".into()))
.build()
.unwrap();
let item = Some(&extension);
assert_eq!(parse_itunes_duration(item), Some(6970));
}
#[test]
fn test_new_podcast_breakthrough() {
let file = File::open("tests/feeds/TheBreakthrough.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let descr = "Latest Articles and Investigations from ProPublica, an independent, \
non-profit newsroom that produces investigative journalism in the public \
interest.";
let pd = new_podcast(&channel, 0);
assert_eq!(pd.title(), "The Breakthrough");
assert_eq!(pd.link(), "http://www.propublica.org/podcast");
assert_eq!(pd.description(), descr);
assert_eq!(
pd.image_uri(),
Some("http://www.propublica.org/images/podcast_logo_2.png")
);
}
#[test]
fn test_new_podcast_lup() {
let file = File::open("tests/feeds/LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let descr = "An open show powered by community LINUX Unplugged takes the best attributes \
of open collaboration and focuses them into a weekly lifestyle show about \
Linux.";
let pd = new_podcast(&channel, 0);
assert_eq!(pd.title(), "LINUX Unplugged Podcast");
assert_eq!(pd.link(), "http://www.jupiterbroadcasting.com/");
assert_eq!(pd.description(), descr);
assert_eq!(
pd.image_uri(),
Some("http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg")
);
}
#[test]
fn test_new_podcast_r4explanation() {
let file = File::open("tests/feeds/R4Explanation.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let pd = new_podcast(&channel, 0);
let descr = "A weekly discussion of Rust RFCs";
assert_eq!(pd.title(), "Request For Explanation");
assert_eq!(
pd.link(),
"https://request-for-explanation.github.io/podcast/"
);
assert_eq!(pd.description(), descr);
assert_eq!(
pd.image_uri(),
Some("https://request-for-explanation.github.io/podcast/podcast.png")
);
}
#[test]
fn test_new_episode_intercepted() {
let file = File::open("tests/feeds/Intercepted.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let firstitem = channel.items().first().unwrap();
let descr = "NSA whistleblower Edward Snowden discusses the massive Equifax data breach \
and allegations of Russian interference in the US election. Commentator \
Shaun King explains his call for a boycott of the NFL and talks about his \
campaign to bring violent neo-Nazis to justice. Rapper Open Mike Eagle \
performs.";
let i = new_episode(&firstitem, 0).unwrap();
assert_eq!(i.title(), "The Super Bowl of Racism");
assert_eq!(
i.uri(),
Some("http://traffic.megaphone.fm/PPY6458293736.mp3")
);
assert_eq!(i.description(), Some(descr));
assert_eq!(i.length(), Some(66738886));
assert_eq!(i.guid(), Some("7df4070a-9832-11e7-adac-cb37b05d5e24"));
assert_eq!(i.published_date(), Some("Wed, 13 Sep 2017 10:00:00 +0000"));
assert_eq!(i.epoch(), 1505296800);
let second = channel.items().iter().nth(1).unwrap();
let i2 = new_episode(&second, 0).unwrap();
let descr2 = "This week on Intercepted: Jeremy gives an update on the aftermath of \
Blackwaters 2007 massacre of Iraqi civilians. Intercept reporter Lee Fang \
lays out how a network of libertarian think tanks called the Atlas Network \
is insidiously shaping political infrastructure in Latin America. We speak \
with attorney and former Hugo Chavez adviser Eva Golinger about the \
Venezuela\'s political turmoil.And we hear Claudia Lizardo of the \
Caracas-based band, La Pequeña Revancha, talk about her music and hopes for \
Venezuela.";
assert_eq!(
i2.title(),
"Atlas Golfed — U.S.-Backed Think Tanks Target Latin America"
);
assert_eq!(
i2.uri(),
Some("http://traffic.megaphone.fm/FL5331443769.mp3")
);
assert_eq!(i2.description(), Some(descr2));
assert_eq!(i2.length(), Some(67527575));
assert_eq!(i2.guid(), Some("7c207a24-e33f-11e6-9438-eb45dcf36a1d"));
assert_eq!(i2.published_date(), Some("Wed, 9 Aug 2017 10:00:00 +0000"));
assert_eq!(i2.epoch(), 1502272800);
}
#[test]
fn test_new_episode_breakthrough() {
let file = File::open("tests/feeds/TheBreakthrough.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let firstitem = channel.items().first().unwrap();
let descr = "<p>A reporter finds that homes meant to replace New Yorks troubled \
psychiatric hospitals might be just as bad.</p>";
let i = new_episode(&firstitem, 0).unwrap();
assert_eq!(
i.title(),
"The Breakthrough: Hopelessness and Exploitation Inside Homes for Mentally Ill"
);
assert_eq!(
i.uri(),
Some("http://tracking.feedpress.it/link/10581/6726758/20170908-cliff-levy.mp3")
);
assert_eq!(i.description(), Some(descr));
assert_eq!(i.length(), Some(33396551));
assert_eq!(
i.guid(),
Some(
"https://www.propublica.org/podcast/\
the-breakthrough-hopelessness-exploitation-homes-for-mentally-ill#134472"
)
);
assert_eq!(i.published_date(), Some("Fri, 8 Sep 2017 12:00:00 +0000"));
assert_eq!(i.epoch(), 1504872000);
let second = channel.items().iter().nth(1).unwrap();
let i2 = new_episode(&second, 0).unwrap();
let descr2 = "<p>Jonathan Allen and Amie Parnes didnt know their book would be called \
Shattered, or that their extraordinary access would let them chronicle \
the mounting signs of a doomed campaign.</p>";
assert_eq!(
i2.title(),
"The Breakthrough: Behind the Scenes of Hillary Clintons Failed Bid for President"
);
assert_eq!(
i2.uri(),
Some("http://tracking.feedpress.it/link/10581/6726759/16_JohnAllen-CRAFT.mp3")
);
assert_eq!(i2.description(), Some(descr2));
assert_eq!(i2.length(), Some(17964071));
assert_eq!(
i2.guid(),
Some(
"https://www.propublica.\
org/podcast/the-breakthrough-hillary-clinton-failed-presidential-bid#133721"
)
);
assert_eq!(i2.published_date(), Some("Fri, 25 Aug 2017 12:00:00 +0000"));
assert_eq!(i2.epoch(), 1503662400);
}
#[test]
fn test_new_episode_lup() {
let file = File::open("tests/feeds/LinuxUnplugged.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let firstitem = channel.items().first().unwrap();
let descr = "Audit your network with a couple of easy commands on Kali Linux. Chris \
decides to blow off a little steam by attacking his IoT devices, Wes has the \
scope on Equifax blaming open source &amp; the Beard just saved the show. \
Its a really packed episode!";
let i = new_episode(&firstitem, 0).unwrap();
assert_eq!(i.title(), "Hacking Devices with Kali Linux | LUP 214");
assert_eq!(
i.uri(),
Some("http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0214.mp3")
);
assert_eq!(i.description(), Some(descr));
assert_eq!(i.length(), Some(46479789));
assert_eq!(i.guid(), Some("78A682B4-73E8-47B8-88C0-1BE62DD4EF9D"));
assert_eq!(i.published_date(), Some("Tue, 12 Sep 2017 22:24:42 -0700"));
assert_eq!(i.epoch(), 1505280282);
let second = channel.items().iter().nth(1).unwrap();
let i2 = new_episode(&second, 0).unwrap();
let descr2 = "<p>The Gnome project is about to solve one of our audience's biggest \
Waylands concerns. But as the project takes on a new level of relevance, \
decisions for the next version of Gnome have us worried about the \
future.</p>\n<p>Plus we chat with Wimpy about the Ubuntu Rally in NYC, \
Microsofts sneaky move to turn Windows 10 into the ULTIMATE LINUX \
RUNTIME, community news &amp; more!</p>";
assert_eq!(i2.title(), "Gnome Does it Again | LUP 213");
assert_eq!(
i2.uri(),
Some("http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0213.mp3")
);
assert_eq!(i2.description(), Some(descr2));
assert_eq!(i2.length(), Some(36544272));
assert_eq!(i2.guid(), Some("1CE57548-B36C-4F14-832A-5D5E0A24E35B"));
assert_eq!(i2.published_date(), Some("Tue, 5 Sep 2017 20:57:27 -0700"));
assert_eq!(i2.epoch(), 1504670247);
}
#[test]
fn test_new_episode_r4expanation() {
let file = File::open("tests/feeds/R4Explanation.xml").unwrap();
let channel = Channel::read_from(BufReader::new(file)).unwrap();
let firstitem = channel.items().iter().nth(9).unwrap();
let descr = "This week we look at <a href=\"https://github.com/rust-lang/rfcs/pull/2094\" \
rel=\"noopener noreferrer\">RFC 2094</a> \"Non-lexical lifetimes\"";
let i = new_episode(&firstitem, 0).unwrap();
assert_eq!(i.title(), "Episode #9 - A Once in a Lifetime RFC");
assert_eq!(
i.uri(),
Some(
"http://request-for-explanation.github.\
io/podcast/ep9-a-once-in-a-lifetime-rfc/episode.mp3"
)
);
assert_eq!(i.description(), Some(descr));
assert_eq!(i.length(), Some(15077388));
assert_eq!(
i.guid(),
Some("https://request-for-explanation.github.io/podcast/ep9-a-once-in-a-lifetime-rfc/")
);
assert_eq!(i.published_date(), Some("Mon, 28 Aug 2017 15:00:00 -0700"));
assert_eq!(i.epoch(), 1503957600);
let second = channel.items().iter().nth(8).unwrap();
let i2 = new_episode(&second, 0).unwrap();
let descr2 = "This week we look at <a \
href=\"https://github.com/rust-lang/rfcs/pull/2071\" rel=\"noopener \
noreferrer\">RFC 2071</a> \"Add impl Trait type alias and variable \
declarations\"";
assert_eq!(i2.title(), "Episode #8 - An Existential Crisis");
assert_eq!(
i2.uri(),
Some(
"http://request-for-explanation.github.\
io/podcast/ep8-an-existential-crisis/episode.mp3"
)
);
assert_eq!(i2.description(), Some(descr2));
assert_eq!(i2.length(), Some(13713219));
assert_eq!(
i2.guid(),
Some("https://request-for-explanation.github.io/podcast/ep8-an-existential-crisis/")
);
assert_eq!(i2.published_date(), Some("Tue, 15 Aug 2017 17:00:00 -0700"));
assert_eq!(i2.epoch(), 1502841600);
}
}

View File

@ -0,0 +1,199 @@
// FIXME:
//! Docs.
use futures::future::*;
use futures_cpupool::CpuPool;
// use futures::prelude::*;
use hyper::Client;
use hyper::client::HttpConnector;
use hyper_tls::HttpsConnector;
use tokio_core::reactor::Core;
use num_cpus;
use rss;
use Source;
use dbqueries;
use errors::*;
use models::{IndexState, NewEpisode, NewEpisodeMinimal};
// use Feed;
use std;
// use std::sync::{Arc, Mutex};
macro_rules! clone {
(@param _) => ( _ );
(@param $x:ident) => ( $x );
($($n:ident),+ => move || $body:expr) => (
{
$( let $n = $n.clone(); )+
move || $body
}
);
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
{
$( let $n = $n.clone(); )+
move |$(clone!(@param $p),)+| $body
}
);
}
/// The pipline to be run for indexing and updating a Podcast feed that originates from
/// `Source.uri`.
///
/// Messy temp diagram:
/// Source -> GET Request -> Update Etags -> Check Status -> Parse xml/Rss ->
/// Convert `rss::Channel` into Feed -> Index Podcast -> Index Episodes.
pub fn pipeline<S: IntoIterator<Item = Source>>(
sources: S,
ignore_etags: bool,
tokio_core: &mut Core,
pool: &CpuPool,
client: Client<HttpsConnector<HttpConnector>>,
) -> Result<()> {
let list: Vec<_> = sources
.into_iter()
.map(clone!(pool => move |s| s.into_feed(&client, pool.clone(), ignore_etags)))
.map(|fut| fut.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index()))))
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
.collect();
if list.is_empty() {
bail!("No futures were found to run.");
}
// Thats not really concurrent yet I think.
tokio_core.run(collect_futures(list))?;
Ok(())
}
/// Creates a tokio `reactor::Core`, a `CpuPool`, and a `hyper::Client` and runs the pipeline.
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<()> {
if sources.is_empty() {
return Ok(());
}
let pool = CpuPool::new_num_cpus();
let mut core = Core::new()?;
let handle = core.handle();
let client = Client::configure()
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
.build(&handle);
pipeline(sources, ignore_etags, &mut core, &pool, client)
}
fn determine_ep_state(ep: NewEpisodeMinimal, item: &rss::Item) -> Result<IndexState<NewEpisode>> {
// 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 = Error> + '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<std::result::Result<F::Item, F::Error>>, Error = Error>>
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)]
mod tests {
use super::*;
use Source;
use database::truncate_db;
// (path, url) tuples.
const URLS: &[(&str, &str)] = {
&[
(
"tests/feeds/2018-01-20-Intercepted.xml",
"https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.\
com/InterceptedWithJeremyScahill",
),
(
"tests/feeds/2018-01-20-LinuxUnplugged.xml",
"https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.\
com/linuxunplugged",
),
(
"tests/feeds/2018-01-20-TheTipOff.xml",
"https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff",
),
(
"tests/feeds/2018-01-20-StealTheStars.xml",
"https://web.archive.org/web/20180120104957if_/https://rss.art19.\
com/steal-the-stars",
),
(
"tests/feeds/2018-01-20-GreaterThanCode.xml",
"https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.\
com/feed/podcast",
),
]
};
#[test]
/// Insert feeds and update/index them.
fn test_pipeline() {
truncate_db().unwrap();
URLS.iter().for_each(|&(_, url)| {
// Index the urls into the source table.
Source::from_url(url).unwrap();
});
let sources = dbqueries::get_sources().unwrap();
run(sources, true).unwrap();
let sources = dbqueries::get_sources().unwrap();
// Run again to cover Unique constrains erros.
run(sources, true).unwrap();
// Assert the index rows equal the controlled results
assert_eq!(dbqueries::get_sources().unwrap().len(), 5);
assert_eq!(dbqueries::get_podcasts().unwrap().len(), 5);
assert_eq!(dbqueries::get_episodes().unwrap().len(), 354);
}
}

View File

@ -5,9 +5,9 @@ table! {
uri -> Nullable<Text>,
local_uri -> Nullable<Text>,
description -> Nullable<Text>,
published_date -> Nullable<Text>,
epoch -> Integer,
length -> Nullable<Integer>,
duration -> Nullable<Integer>,
guid -> Nullable<Text>,
played -> Nullable<Integer>,
favorite -> Bool,
@ -38,3 +38,5 @@ table! {
http_etag -> Nullable<Text>,
}
}
allow_tables_to_appear_in_same_query!(episode, podcast, source,);

View File

@ -1,65 +1,63 @@
//! Helper utilities for accomplishing various tasks.
use rayon::prelude::*;
use chrono::prelude::*;
use rayon::prelude::*;
use url::{Position, Url};
use itertools::Itertools;
use url::{Position, Url};
use errors::*;
use dbqueries;
use models::queryables::Episode;
use errors::*;
use models::{EpisodeCleanerQuery, Podcast, Save};
use xdg_dirs::DL_DIR;
use std::path::Path;
use std::fs;
use std::path::Path;
/// Scan downloaded `episode` entries that might have broken `local_uri`s and set them to `None`.
fn download_checker() -> Result<()> {
let episodes = dbqueries::get_downloaded_episodes()?;
let mut episodes = dbqueries::get_downloaded_episodes()?;
episodes
.into_par_iter()
.for_each(|mut ep| checker_helper(&mut ep));
.par_iter_mut()
.filter(|ep| !Path::new(ep.local_uri().unwrap()).exists())
.for_each(|ep| {
ep.set_local_uri(None);
if let Err(err) = ep.save() {
error!("Error while trying to update episode: {:#?}", ep);
error!("Error: {}", err);
};
});
Ok(())
}
fn checker_helper(ep: &mut Episode) {
if !Path::new(ep.local_uri().unwrap()).exists() {
ep.set_local_uri(None);
let res = ep.save();
if let Err(err) = res {
error!("Error while trying to update episode: {:#?}", ep);
error!("Error: {}", err);
};
}
}
/// Delete watched `episodes` that have exceded their liftime after played.
fn played_cleaner() -> Result<()> {
let episodes = dbqueries::get_played_episodes()?;
let mut episodes = dbqueries::get_played_cleaner_episodes()?;
let now_utc = Utc::now().timestamp() as i32;
episodes.into_par_iter().for_each(|mut ep| {
if ep.local_uri().is_some() && ep.played().is_some() {
let played = ep.played().unwrap();
episodes
.par_iter_mut()
.filter(|ep| ep.local_uri().is_some() && ep.played().is_some())
.for_each(|ep| {
// TODO: expose a config and a user set option.
// Chnage the test too when exposed
let limit = played + 172_800; // add 2days in seconds
let limit = ep.played().unwrap() + 172_800; // add 2days in seconds
if now_utc > limit {
let e = delete_local_content(&mut ep);
if let Err(err) = e {
if let Err(err) = delete_local_content(ep) {
error!("Error while trying to delete file: {:?}", ep.local_uri());
error!("Error: {}", err);
} else {
info!("Episode {:?} was deleted succesfully.", ep.title());
info!("Episode {:?} was deleted succesfully.", ep.local_uri());
};
}
}
});
});
Ok(())
}
/// Check `ep.local_uri` field and delete the file it points to.
pub fn delete_local_content(ep: &mut Episode) -> Result<()> {
fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<()> {
if ep.local_uri().is_some() {
let uri = ep.local_uri().unwrap().to_owned();
if Path::new(&uri).exists() {
@ -89,8 +87,10 @@ pub fn delete_local_content(ep: &mut Episode) -> Result<()> {
/// Runs a cleaner for played Episode's that are pass the lifetime limit and
/// scheduled for removal.
pub fn checkup() -> Result<()> {
info!("Running database checks.");
download_checker()?;
played_cleaner()?;
info!("Checks completed.");
Ok(())
}
@ -106,7 +106,7 @@ pub fn url_cleaner(s: &str) -> String {
}
}
/// Helper functions that strips extra spaces and newlines and all the tabs.
/// Helper functions that strips extra spaces and newlines and ignores the tabs.
#[allow(match_same_arms)]
pub fn replace_extra_spaces(s: &str) -> String {
s.trim()
@ -122,14 +122,64 @@ pub fn replace_extra_spaces(s: &str) -> String {
.collect::<String>()
}
/// Returns the URI of a Podcast Downloads given it's title.
pub fn get_download_folder(pd_title: &str) -> Result<String> {
// It might be better to make it a hash of the title or the podcast rowid
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
// Create the folder
fs::DirBuilder::new()
.recursive(true)
.create(&download_fold)?;
Ok(download_fold)
}
/// Removes all the entries associated with the given show from the database,
/// and deletes all of the downloaded content.
// TODO: Write Tests
pub fn delete_show(pd: &Podcast) -> Result<()> {
dbqueries::remove_feed(pd)?;
info!("{} was removed succesfully.", pd.title());
let fold = get_download_folder(pd.title())?;
fs::remove_dir_all(&fold)?;
info!("All the content at, {} was removed succesfully", &fold);
Ok(())
}
#[cfg(test)]
use Feed;
#[cfg(test)]
/// Helper function that open a local file, parse the rss::Channel and gives back a Feed object.
/// Alternative Feed constructor to be used for tests.
pub fn get_feed(file_path: &str, id: i32) -> Feed {
use feed::FeedBuilder;
use rss::Channel;
use std::fs;
use std::io::BufReader;
// open the xml file
let feed = fs::File::open(file_path).unwrap();
// parse it into a channel
let chan = Channel::read_from(BufReader::new(feed)).unwrap();
FeedBuilder::default()
.channel(chan)
.source_id(id)
.build()
.unwrap()
}
#[cfg(test)]
mod tests {
extern crate tempdir;
use super::*;
use database::{connection, truncate_db};
use models::insertables::NewEpisodeBuilder;
use self::tempdir::TempDir;
use super::*;
use database::truncate_db;
use models::NewEpisodeBuilder;
use std::fs::File;
use std::io::Write;
@ -144,14 +194,12 @@ mod tests {
writeln!(tmp_file, "Foooo").unwrap();
// Setup episodes
let db = connection();
let con = db.get().unwrap();
let n1 = NewEpisodeBuilder::default()
.title("foo_bar".to_string())
.podcast_id(0)
.build()
.unwrap()
.into_episode(&con)
.to_episode()
.unwrap();
let n2 = NewEpisodeBuilder::default()
@ -159,17 +207,14 @@ mod tests {
.podcast_id(1)
.build()
.unwrap()
.into_episode(&con)
.to_episode()
.unwrap();
let mut ep1 =
dbqueries::get_episode_from_new_episode(&con, n1.title(), n1.podcast_id()).unwrap();
let mut ep2 =
dbqueries::get_episode_from_new_episode(&con, n2.title(), n2.podcast_id()).unwrap();
let mut ep1 = dbqueries::get_episode_from_pk(n1.title(), n1.podcast_id()).unwrap();
let mut ep2 = dbqueries::get_episode_from_pk(n2.title(), n2.podcast_id()).unwrap();
ep1.set_local_uri(Some(valid_path.to_str().unwrap()));
ep2.set_local_uri(Some(bad_path.to_str().unwrap()));
drop(con);
ep1.save().unwrap();
ep2.save().unwrap();
@ -178,35 +223,28 @@ mod tests {
#[test]
fn test_download_checker() {
let _tmp_dir = helper_db();
let tmp_dir = helper_db();
download_checker().unwrap();
let episodes = dbqueries::get_downloaded_episodes().unwrap();
let valid_path = tmp_dir.path().join("virtual_dl.mp3");
assert_eq!(episodes.len(), 1);
assert_eq!("foo_bar", episodes.first().unwrap().title());
}
assert_eq!(
Some(valid_path.to_str().unwrap()),
episodes.first().unwrap().local_uri()
);
#[test]
fn test_checker_helper() {
let _tmp_dir = helper_db();
let mut episode = {
let db = connection();
let con = db.get().unwrap();
dbqueries::get_episode_from_new_episode(&con, "bar_baz", 1).unwrap()
};
checker_helper(&mut episode);
download_checker().unwrap();
let episode = dbqueries::get_episode_from_pk("bar_baz", 1).unwrap();
assert!(episode.local_uri().is_none());
}
#[test]
fn test_download_cleaner() {
let _tmp_dir = helper_db();
let mut episode = {
let db = connection();
let con = db.get().unwrap();
dbqueries::get_episode_from_new_episode(&con, "foo_bar", 0).unwrap()
};
let mut episode: EpisodeCleanerQuery =
dbqueries::get_episode_from_pk("foo_bar", 0).unwrap().into();
let valid_path = episode.local_uri().unwrap().to_owned();
delete_local_content(&mut episode).unwrap();
@ -216,11 +254,7 @@ mod tests {
#[test]
fn test_played_cleaner_expired() {
let _tmp_dir = helper_db();
let mut episode = {
let db = connection();
let con = db.get().unwrap();
dbqueries::get_episode_from_new_episode(&con, "foo_bar", 0).unwrap()
};
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let now_utc = Utc::now().timestamp() as i32;
// let limit = now_utc - 172_800;
let epoch = now_utc - 200_000;
@ -236,11 +270,7 @@ mod tests {
#[test]
fn test_played_cleaner_none() {
let _tmp_dir = helper_db();
let mut episode = {
let db = connection();
let con = db.get().unwrap();
dbqueries::get_episode_from_new_episode(&con, "foo_bar", 0).unwrap()
};
let mut episode = dbqueries::get_episode_from_pk("foo_bar", 0).unwrap();
let now_utc = Utc::now().timestamp() as i32;
// limit = 172_800;
let epoch = now_utc - 20_000;
@ -280,4 +310,11 @@ mod tests {
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
}
#[test]
fn test_get_dl_folder() {
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
assert_eq!(get_download_folder("foo").unwrap(), foo_);
let _ = fs::remove_dir_all(foo_);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,11 @@
<language>en</language>
<copyright>First Look Media Works, Inc.</copyright>
<description>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.</description>
<image>
<url>http://static.megaphone.fm/podcasts/d5735a50-d904-11e6-8532-73c7de466ea6/image/uploads_2F1484252190700-qhn5krasklbce3dh-a797539282700ea0298a3a26f7e49b0b_2FIntercepted_COVER%2B_281_29.png</url>
<title>Intercepted with Jeremy Scahill</title>
<link>https://theintercept.com/podcasts</link>
</image>
<itunes:explicit>no</itunes:explicit>
<itunes:type>episodic</itunes:type>
<itunes:subtitle>The people behind The Intercepts fearless reporting and incisive commentary discuss the crucial issues of our time.</itunes:subtitle>
@ -20,6 +25,230 @@
<itunes:category text="News &amp; Politics">
</itunes:category>
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" type="application/rss+xml" href="http://feeds.feedburner.com/InterceptedWithJeremyScahill" /><feedburner:info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" uri="interceptedwithjeremyscahill" /><atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub" href="http://pubsubhubbub.appspot.com/" /><item>
<title>White Mirror</title>
<description>Jeremy lays out the bloody US history in Haiti and El Salvador and blasts the bipartisan, selective amnesia and historical revisionism that “American exceptionalism” demands. Rep. Tulsi Gabbard discusses U.S. regime change, North Korea and why Bernie Sanders would have defeated Trump. As Robert Mueller hits Bannon with a Grand Jury subpoena, former CIA operative and&amp;nbsp; Cipher Brief columnist John Sipher and journalist Marcy Wheeler of Emptywheel analyze the Russia investigation and the Steele dossier. Leading Marxist scholar David Harvey talks about debt peonage in the age of Trump and the crimes of capitalism.</description>
<pubDate>Wed, 17 Jan 2018 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>White Mirror</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Donald Trump is a racist and the perfect man to represent Americas racist legacy in the countries he called shitholes. </itunes:subtitle>
<itunes:summary>
<![CDATA[Jeremy lays out the bloody US history in Haiti and El Salvador and blasts the bipartisan, selective amnesia and historical revisionism that “American exceptionalism” demands. Rep. Tulsi Gabbard discusses U.S. regime change, North Korea and why Bernie Sanders would have defeated Trump. As Robert Mueller hits Bannon with a Grand Jury subpoena, former CIA operative and&nbsp; Cipher Brief columnist John Sipher and journalist Marcy Wheeler of Emptywheel analyze the Russia investigation and the Steele dossier. Leading Marxist scholar David Harvey talks about debt peonage in the age of Trump and the crimes of capitalism.]]>
</itunes:summary>
<itunes:duration>6409</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[3660ad94-fb38-11e7-847d-436a066985fa]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY1407171456.mp3?updated=1516180736" length="102550465" type="audio/mpeg" />
</item>
<item>
<title>BONUS: All The News Unfit to Print</title>
<description>James Risen is a legend in the world of investigative and national security journalism. As a reporter for the New York Times, Risen broke some of the most important stories of the post 9/11 era, from the warrantless surveillance against Americans conducted under the Bush-Cheney administration, to black prison sites run by the CIA, to failed covert actions in Iran. Risen has won the Pulitzer and other major journalism awards. But perhaps what he is now most famous for is fighting a battle under both the Bush and Obama administrations as they demanded — under threat of imprisonment —the name of one of Risens alleged confidential sources. But it isnt just the government that Risen had to fight. He also battled his own editors and other powerful figures at the New York Times. Risen is now a senior national security correspondent at The Intercept where his incredible inside story has now been published. We talk with Risen about his career at the New York Times in a special edition of Intercepted.</description>
<pubDate>Wed, 03 Jan 2018 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>BONUS: All The News Unfit to Print</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>James Risen on His Battles with Bush, Obama, and the New York Times</itunes:subtitle>
<itunes:summary>
<![CDATA[James Risen is a legend in the world of investigative and national security journalism. As a reporter for the New York Times, Risen broke some of the most important stories of the post 9/11 era, from the warrantless surveillance against Americans conducted under the Bush-Cheney administration, to black prison sites run by the CIA, to failed covert actions in Iran. Risen has won the Pulitzer and other major journalism awards. But perhaps what he is now most famous for is fighting a battle under both the Bush and Obama administrations as they demanded — under threat of imprisonment —the name of one of Risens alleged confidential sources. But it isnt just the government that Risen had to fight. He also battled his own editors and other powerful figures at the New York Times. Risen is now a senior national security correspondent at The Intercept where his incredible inside story has now been published. We talk with Risen about his career at the New York Times in a special edition of Intercepted.]]>
</itunes:summary>
<itunes:duration>3805</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[6bdd6660-f039-11e7-acba-33ffde0bb3cc]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY1217453507.mp3" length="60884950" type="audio/mpeg" />
</item>
<item>
<title>Full Metal Jackass</title>
<description>Former Nixon White House counsel John Dean talks about the Mueller investigation, how the CIA may benefit from Trumps presidency and how Trump stacks up to Nixon and Reagan. Pentagon Papers whistleblower Daniel Ellsberg talks about the classified secrets he has kept for decades. He has just published his story in a new book, The Doomsday Machine. Field of Vision takes us inside the very strange world of Steve Bannons films. Patterson Hood of the band Drive-By Truckers performs.</description>
<pubDate>Wed, 13 Dec 2017 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Full Metal Jackass</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Former Nixon Lawyer John Dean and Daniel Ellsberg Analyze the Trump Moment</itunes:subtitle>
<itunes:summary>
<![CDATA[Former Nixon White House counsel John Dean talks about the Mueller investigation, how the CIA may benefit from Trumps presidency and how Trump stacks up to Nixon and Reagan. Pentagon Papers whistleblower Daniel Ellsberg talks about the classified secrets he has kept for decades. He has just published his story in a new book, The Doomsday Machine. Field of Vision takes us inside the very strange world of Steve Bannons films. Patterson Hood of the band Drive-By Truckers performs.]]>
</itunes:summary>
<itunes:duration>5670</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bb062002-9d9f-11e7-b8c4-9701f8d8d38e]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY9016904056.mp3" length="90720966" type="audio/mpeg" />
</item>
<item>
<title>Who's Afraid of the Alt-Deep State?</title>
<description>Matthew Cole joins Jeremy for a discussion about their explosive report in The Intercept that Blackwater founder Erik Prince has been pitching a private spy operation to the White House and CIA. Activist and comedian Randy Credico, who has been hit with a subpoena from the House Intelligence Committee investigating Trump and Russia, joins us.&amp;nbsp; Journalist Barrett Brown talks about the FBIs campaign against him and offers a critique of Wikileaks. Singer Amanda Palmer talks about her provocative new video for a cover she did of Pink Floyds “Mother."</description>
<pubDate>Wed, 06 Dec 2017 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Who's Afraid of the Alt-Deep State?</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Donald Trump wants to make 1980s Reagan-era covert wars great again.</itunes:subtitle>
<itunes:summary>
<![CDATA[Matthew Cole joins Jeremy for a discussion about their explosive report in The Intercept that Blackwater founder Erik Prince has been pitching a private spy operation to the White House and CIA. Activist and comedian Randy Credico, who has been hit with a subpoena from the House Intelligence Committee investigating Trump and Russia, joins us.&nbsp; Journalist Barrett Brown talks about the FBIs campaign against him and offers a critique of Wikileaks. Singer Amanda Palmer talks about her provocative new video for a cover she did of Pink Floyds “Mother."]]>
</itunes:summary>
<itunes:duration>6260</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bafb58fc-9d9f-11e7-b8c4-070c14a1debb]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY9210981870.mp3" length="100160574" type="audio/mpeg" />
</item>
<item>
<title>Very Bad Men</title>
<description>This week on Intercepted: Sen. Chris Murphy blasts the US government for its role in the destruction of Yemen. Jeremy tears apart Thomas Friedmans gross love letter to the Saudi Crown Prince and talks about the bi-partisan war against journalism from Bill Clinton to Donald Trump. The Intercepts Betsy Reed and Buzzfeeds Katie Baker analyze this unprecedented public fight against sexual assaulters. Analysis from Harare, Zimbabwe on the ouster of Robert Mugabe. Comedian Joe Para performs a dramatic reenactment of a secret Snowden document.</description>
<pubDate>Wed, 29 Nov 2017 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Very Bad Men</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Trump, the Saudi Crown Prince, Sexual Assaulters, and Robert Mugabe</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted: Sen. Chris Murphy blasts the US government for its role in the destruction of Yemen. Jeremy tears apart Thomas Friedmans gross love letter to the Saudi Crown Prince and talks about the bi-partisan war against journalism from Bill Clinton to Donald Trump. The Intercepts Betsy Reed and Buzzfeeds Katie Baker analyze this unprecedented public fight against sexual assaulters. Analysis from Harare, Zimbabwe on the ouster of Robert Mugabe. Comedian Joe Para performs a dramatic reenactment of a secret Snowden document.]]>
</itunes:summary>
<itunes:duration>5565</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[baf3e644-9d9f-11e7-b8c4-9f5a004c8b47]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY5979450332.mp3?updated=1511943682" length="89042024" type="audio/mpeg" />
</item>
<item>
<title>The Distraction in Chief</title>
<description>This week on Intercepted: Rami Khouri breaks down the Saudi agenda in the Middle East, its destruction of Yemen and the bizarre case of the exiled Lebanese prime minister. Aram Roston of Buzzfeed, Spencer Ackerman of the Daily Beast, and The Intercepts Matthew Cole join Jeremy for a discussion on the mysterious death of a Green Beret in Mali and why the CIA and US military are quite content with the Trump presidency. Wikileaks slid into Donald Trump Jr.s DMs. Intercept co-founder Glenn Greenwald analyzes what the messages say and how the media covered the story. And we talk to two newly elected Democrats in Virginia: Lee Carter and Elizabeth Guzman. Donald Trump stars in American Beauty.</description>
<pubDate>Wed, 15 Nov 2017 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>The Distraction in Chief</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>While the media overwhelmingly focuses on Trump and Russia, Yemen is dying, covert ops are spreading and war is raging.</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted: Rami Khouri breaks down the Saudi agenda in the Middle East, its destruction of Yemen and the bizarre case of the exiled Lebanese prime minister. Aram Roston of Buzzfeed, Spencer Ackerman of the Daily Beast, and The Intercepts Matthew Cole join Jeremy for a discussion on the mysterious death of a Green Beret in Mali and why the CIA and US military are quite content with the Trump presidency. Wikileaks slid into Donald Trump Jr.s DMs. Intercept co-founder Glenn Greenwald analyzes what the messages say and how the media covered the story. And we talk to two newly elected Democrats in Virginia: Lee Carter and Elizabeth Guzman. Donald Trump stars in American Beauty.]]>
</itunes:summary>
<itunes:duration>5716</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[baec6cf2-9d9f-11e7-b8c4-832adc6b044a]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY5077597385.mp3" length="91468695" type="audio/mpeg" />
</item>
<item>
<title>Say Hello to My Little Hands</title>
<description>This week on Intercepted: Rep. Ro Khanna calls for a complete end to all U.S. military assistance to Saudi Arabia and the&amp;nbsp; catastrophe in Yemen. The former chief prosecutor at Guantanamo, Col. Morris Davis, blasts Trump over his interference in the case of Army Sergeant Bowe Bergdahl and the recent terror attack in New York. And as the Paradise Papers rock the world of the rich who use offshore banks and law firms, we get analysis from Nomi Prins.</description>
<pubDate>Wed, 08 Nov 2017 11:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Say Hello to My Little Hands</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>True (War) Crimes of the Rich and Infamous</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted: Rep. Ro Khanna calls for a complete end to all U.S. military assistance to Saudi Arabia and the&nbsp; catastrophe in Yemen. The former chief prosecutor at Guantanamo, Col. Morris Davis, blasts Trump over his interference in the case of Army Sergeant Bowe Bergdahl and the recent terror attack in New York. And as the Paradise Papers rock the world of the rich who use offshore banks and law firms, we get analysis from Nomi Prins.]]>
</itunes:summary>
<itunes:duration>5476</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bae4af26-9d9f-11e7-b8c4-d7bd1cbbac44]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY3384065210.mp3" length="87615947" type="audio/mpeg" />
</item>
<item>
<title>Criminal Indictments at Home, Secret Wars Abroad</title>
<description>This week on Intercepted: New York Times reporter Charlie Savage and former federal prosecutor Ken White of Popehat break down the recent indictment and plea deal and what it may mean for Trump. Investigative journalist Nick Turse and Kenya scholar Samar Al-Bulushi take us into the world of US militarism in Africa: secret drone bases, US commandos and Washington-backed African forces operating under the guise of the “war on terror.” Musician Roberto Lange of Helado Negro performs.</description>
<pubDate>Wed, 01 Nov 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Criminal Indictments at Home, Secret Wars Abroad</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Robert Muellers investigation intensifies as Trump grants the CIA and military new kill powers.</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted: New York Times reporter Charlie Savage and former federal prosecutor Ken White of Popehat break down the recent indictment and plea deal and what it may mean for Trump. Investigative journalist Nick Turse and Kenya scholar Samar Al-Bulushi take us into the world of US militarism in Africa: secret drone bases, US commandos and Washington-backed African forces operating under the guise of the “war on terror.” Musician Roberto Lange of Helado Negro performs.]]>
</itunes:summary>
<itunes:duration>4504</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[badd1b62-9d9f-11e7-b8c4-bb0a3510ea9d]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY9002073032.mp3" length="72073299" type="audio/mpeg" />
</item>
<item>
<title>Mike Pence is The Koch Brothers' Manchurian Candidate</title>
<description>This week on Intercepted: Investigative journalist Jane Mayer exposes the Koch Brother puppet masters behind Vice President Mike Pences rise to power and the ruthless pursuit of corporate profits that put Pence a heartbeat from the presidency.We speak to Chinese dissident and renown artist Ai Weiwei about the humanitarian catastrophe of the 65 million globally displaced migrants and his new documentary, Human Flow. And we end with Deerhoof's Greg Saunier on the songs of “Mountain Moves.”</description>
<pubDate>Wed, 25 Oct 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Mike Pence is The Koch Brothers' Manchurian Candidate</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>The ruthless pursuit of corporate profits is a heartbeat from the presidency.</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted: Investigative journalist Jane Mayer exposes the Koch Brother puppet masters behind Vice President Mike Pences rise to power and the ruthless pursuit of corporate profits that put Pence a heartbeat from the presidency.We speak to Chinese dissident and renown artist Ai Weiwei about the humanitarian catastrophe of the 65 million globally displaced migrants and his new documentary, Human Flow. And we end with Deerhoof's Greg Saunier on the songs of “Mountain Moves.”]]>
</itunes:summary>
<itunes:duration>4458</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bad56944-9d9f-11e7-b8c4-036f3898314a]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY6525793662.mp3?updated=1508912939" length="71329332" type="audio/mpeg" />
</item>
<item>
<title>Canada is Racist Too</title>
<description>This week on Intercepted live from Toronto: A recent poll puts activist Desmond Cole in prime position to win the mayorship. We talk to him about Canadas stop and frisk and how Cole would change Toronto. Journalist Naomi Klein warns that the Trudeau and Trump brands may have more in common than expected. And returning Iraqi-Canadian hip-hop artist Narcy gives a powerful live performance.&lt;br&gt;&lt;br&gt;Become a sustaining member! Go to &lt;a href="https://theintercept.com/join"&gt;theintercept.com/join&lt;/a&gt; for more.</description>
<pubDate>Wed, 18 Oct 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Canada is Racist Too</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Could a young, radical black activist be the next mayor of Toronto?</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted live from Toronto: A recent poll puts activist Desmond Cole in prime position to win the mayorship. We talk to him about Canadas stop and frisk and how Cole would change Toronto. Journalist Naomi Klein warns that the Trudeau and Trump brands may have more in common than expected. And returning Iraqi-Canadian hip-hop artist Narcy gives a powerful live performance.<br><br>Become a sustaining member! Go to <a href="https://theintercept.com/join">theintercept.com/join</a> for more.]]>
</itunes:summary>
<itunes:duration>4613</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bacdc1bc-9d9f-11e7-b8c4-bf06486207e8]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY1212140419.mp3" length="73809084" type="audio/mpeg" />
</item>
<item>
<title>The White Stuff</title>
<description>Trump sent Mike Pence on a mission to protest black protesters at an NFL game. Acclaimed author and journalist Ta-Nehisi Coates talks about Trump, Obama, Bernie Sanders, Hillary Clinton, the NFL and much more. Mehrsa Baradaran breaks down the roots of economic apartheid in the US, the ongoing impact of slavery on black communities and offers a provocative history of black banks. And the lead singer of Mashrou Leila, Hamed Sinno, talks about being queer and Arab in the Middle East and Trumps America.</description>
<pubDate>Wed, 11 Oct 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>The White Stuff</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Ta-Nehisi Coates talks about Trump, Obama, Bernie Sanders, Hillary Clinton, the NFL and much more.</itunes:subtitle>
<itunes:summary>
<![CDATA[Trump sent Mike Pence on a mission to protest black protesters at an NFL game. Acclaimed author and journalist Ta-Nehisi Coates talks about Trump, Obama, Bernie Sanders, Hillary Clinton, the NFL and much more. Mehrsa Baradaran breaks down the roots of economic apartheid in the US, the ongoing impact of slavery on black communities and offers a provocative history of black banks. And the lead singer of Mashrou Leila, Hamed Sinno, talks about being queer and Arab in the Middle East and Trumps America.]]>
</itunes:summary>
<itunes:duration>5664</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bac62452-9d9f-11e7-b8c4-17ec33943c11]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY7057322394.mp3?updated=1507702418" length="90635702" type="audio/mpeg" />
</item>
<item>
<title>Guns Before Country</title>
<description>This week, Jeremy talks about the Coalition of the Killing — gun lobbyists, politicians and weapons manufacturers — the only beneficiaries of the massacre in Las Vegas. Alynda Segarra of the band Hurray for the Riff Raff explores her Puerto Rican roots and performs new songs. Former US Army Ranger Rory Fanning talks about his slain comrade, NFL star-turned soldier Pat Tillman. Historian Jeanne Theoharis shreds the sanitizing of the legacies of Martin Luther King Jr. and Rosa Parks. And Donald Trump takes his love of guns into the Twilight Zone.&lt;br&gt;&lt;br&gt;Support our show — become a member!&amp;nbsp; &lt;a href="http://theintercept.com/join"&gt;theintercept.com/join&lt;/a&gt;&lt;br&gt;&lt;br&gt;Panoply's podcast listener survey: &lt;a href="http://survey.panoply.fm"&gt;survey.panoply.fm&lt;/a&gt;</description>
<pubDate>Wed, 04 Oct 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>Guns Before Country</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Lobbyists, politicians and weapons manufacturers are the only beneficiaries of the massacre in Las Vegas. </itunes:subtitle>
<itunes:summary>
<![CDATA[This week, Jeremy talks about the Coalition of the Killing — gun lobbyists, politicians and weapons manufacturers — the only beneficiaries of the massacre in Las Vegas. Alynda Segarra of the band Hurray for the Riff Raff explores her Puerto Rican roots and performs new songs. Former US Army Ranger Rory Fanning talks about his slain comrade, NFL star-turned soldier Pat Tillman. Historian Jeanne Theoharis shreds the sanitizing of the legacies of Martin Luther King Jr. and Rosa Parks. And Donald Trump takes his love of guns into the Twilight Zone.<br><br>Support our show — become a member!&nbsp; <a href="http://theintercept.com/join">theintercept.com/join</a><br><br>Panoply's podcast listener survey: <a href="http://survey.panoply.fm">survey.panoply.fm</a>]]>
</itunes:summary>
<itunes:duration>4763</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[babd79c4-9d9f-11e7-b8c4-bfa2bf7b2870]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY1463981678.mp3" length="76216529" type="audio/mpeg" />
</item>
<item>
<title>For Whom the Trump Trolls</title>
<description>This week on Intercepted, physicist David Wright from the Union of Concerned Scientists explains how easy it would be for Trump to launch a nuclear strike. Professor James Fernandez of NYU talks about the Abraham Lincoln Brigade, the 3,000 Americans who tried to stop fascism before it spread in Europe. We speak with the directors of a haunting new film about a terror attack in an Israeli bus station that leads to the brutal mob killing of an innocent Eritrean immigrant. And Donald Trump gets a visit from the two Bobs in his Office Space.</description>
<pubDate>Wed, 27 Sep 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>For Whom the Trump Trolls</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>What the Abraham Lincoln Brigade can teach us about fighting fascism in the 21st century.</itunes:subtitle>
<itunes:summary>
<![CDATA[This week on Intercepted, physicist David Wright from the Union of Concerned Scientists explains how easy it would be for Trump to launch a nuclear strike. Professor James Fernandez of NYU talks about the Abraham Lincoln Brigade, the 3,000 Americans who tried to stop fascism before it spread in Europe. We speak with the directors of a haunting new film about a terror attack in an Israeli bus station that leads to the brutal mob killing of an innocent Eritrean immigrant. And Donald Trump gets a visit from the two Bobs in his Office Space.]]>
</itunes:summary>
<itunes:duration>4754</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[bab3035e-9d9f-11e7-b8c4-a723efdad05b]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY7212168126.mp3?updated=1506529903" length="76071497" type="audio/mpeg" />
</item>
<item>
<title>'Merican Psycho</title>
<description>Jeremy analyzes Trumps belligerent UN speech and the massive military budget the Democrats just gave him. Journalist Gary Rivlin takes us deep inside the world of the Goldman Sachs executives now working for Trump. Poet Aja Monet performs. The Intercepts Alice Speri investigates the militarization of police and how Israel is training American cops. Plus, Donald Trump stars in American Psycho.</description>
<pubDate>Wed, 20 Sep 2017 10:00:00 -0000</pubDate>
<itunes:author>The Intercept / Panoply</itunes:author>
<itunes:title>'Merican Psycho</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:subtitle>Donald Trump visits the UN and returns some videotapes.</itunes:subtitle>
<itunes:summary>
<![CDATA[Jeremy analyzes Trumps belligerent UN speech and the massive military budget the Democrats just gave him. Journalist Gary Rivlin takes us deep inside the world of the Goldman Sachs executives now working for Trump. Poet Aja Monet performs. The Intercepts Alice Speri investigates the militarization of police and how Israel is training American cops. Plus, Donald Trump stars in American Psycho.]]>
</itunes:summary>
<itunes:duration>4370</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[baa7cb06-9d9f-11e7-b8c4-d7ae2711974f]]></guid>
<enclosure url="http://traffic.megaphone.fm/PPY8078356160.mp3" length="69924570" type="audio/mpeg" />
</item>
<item>
<title>The Super Bowl of Racism</title>
<description>NSA whistleblower Edward Snowden discusses the massive Equifax data breach and allegations of Russian interference in the US election. Commentator Shaun King explains his call for a boycott of the NFL and talks about his campaign to bring violent neo-Nazis to justice. Rapper Open Mike Eagle performs.</description>
<pubDate>Wed, 13 Sep 2017 10:00:00 -0000</pubDate>
@ -45,7 +274,7 @@
<itunes:summary>
<![CDATA[This week on Intercepted: Jeremy gives an update on the aftermath of Blackwaters 2007 massacre of Iraqi civilians. Intercept reporter Lee Fang lays out how a network of libertarian think tanks called the Atlas Network is insidiously shaping political infrastructure in Latin America. We speak with attorney and former Hugo Chavez adviser Eva Golinger about the Venezuela's political turmoil.And we hear Claudia Lizardo of the Caracas-based band, La Pequeña Revancha, talk about her music and hopes for Venezuela.]]>
</itunes:summary>
<itunes:duration>4220</itunes:duration>
<itunes:duration>4415</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[7c207a24-e33f-11e6-9438-eb45dcf36a1d]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5331443769.mp3" length="67527575" type="audio/mpeg" />
@ -60,7 +289,7 @@
<itunes:summary>
<![CDATA[News from the White House this week has been like a twisted mash up of Here Comes Honey Boo Boo, Macbeth, Project Runway and a Mr. Bean movie. Dime-store Sopranos reject Anthony Scaramucci was fired after just 10 days as White House communications director. Reince Priebus is out as chief of staff, Gen. John Kelly is in. And with spiking tensions between the United States and North Korea, we reflect on the history of the region. Plus, The Intercepts Naomi Klein talks to U.K. Labour Party leader Jeremy Corbyn about the lessons the Democratic Party could learn from Corbyns unexpected electoral success.]]>
</itunes:summary>
<itunes:duration>3517</itunes:duration>
<itunes:duration>3712</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[5850753c-dcf9-11e6-a5a2-a7df163d0693]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL4502761802.mp3" length="56280711" type="audio/mpeg" />
@ -75,7 +304,7 @@
<itunes:summary>
<![CDATA[With all the constant hype about Russia, youd think we were living in a new Cold War. This week on Intercepted: Glenn Greenwald fills in for Jeremy Scahill, and we take a deep dive into the origins and evolution of the Trump-Russia story. Fox News' Tucker Carlson and Glenn find something they can actually agree on (the Democratic establishments Russia hysteria), but diverge on Tuckers coverage of immigration and crime. Russian-American writer Masha Gessen explains how conspiracy thinking is a mirror of the leaders we put in power.]]>
</itunes:summary>
<itunes:duration>3370</itunes:duration>
<itunes:duration>3565</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[584711b8-dcf9-11e6-a5a2-d7a378461c40]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL8633314507.mp3" length="53935124" type="audio/mpeg" />
@ -90,7 +319,7 @@
<itunes:summary>
<![CDATA[Donald Trump enjoyed playing fireman and asking where the fire is. Hint: all around you, Mr. President. This week on Intercepted: the famed rebel academic, Alfred McCoy, whose book on narcotrafficking the CIA tried to stop from being published, lays out his meticulously argued theory that the U.S. empire will fall by the year 2030. The Washington Posts media columnist, Margaret Sullivan, talks about Trump ratcheting up the war on whistleblowers and the existence of a free press.]]>
</itunes:summary>
<itunes:duration>3951</itunes:duration>
<itunes:duration>4146</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[583dc8f6-dcf9-11e6-a5a2-97233491f3c8]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL4964577496.mp3" length="63216744" type="audio/mpeg" />
@ -105,7 +334,7 @@
<itunes:summary>
<![CDATA[This week on Intercepted: Don Jr. is in the shit throne over a secret meeting he had with a Russian lawyer. Could this be, as many in the media are claiming, the smoking gun of Russia collusion? Intercept co-founder Glenn Greenwald weighs in and debunks a forged NSA document sent to Rachel Maddow. Intercept reporters Alice Speri and Alleen Brown talk about the shadowy mercenary company TigerSwan. We also hear music from Victoria Ruiz of the punk band Downtown Boys.]]>
</itunes:summary>
<itunes:duration>3864</itunes:duration>
<itunes:duration>4059</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[5834b428-dcf9-11e6-a5a2-f7aca16eec6e]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5169968320.mp3" length="61824940" type="audio/mpeg" />
@ -120,7 +349,7 @@
<itunes:summary>
<![CDATA[President Trump said when it comes to health insurance, he would cover everyone. He lied. Meanwhile the Crown Prince of America, Jared Kushner, and Mohammed Bin Salman, Crown Prince of Saudi Arabia, play house with foreign policy. This week: Al Jazeeras Mehdi Hasan fills in for Jeremy Scahill. Intercept reporter Murtaza Hussain and journalist Rula Jebreal discuss the global consequences of the House of Trumps meddling in the Middle East. Historian Tom Holland joins Mehdi for a debate on the role of Islam within the Islamic State. Plus, actor Bill Camp reprises his role as the “SIGINT Philosopher.”]]>
</itunes:summary>
<itunes:duration>3402</itunes:duration>
<itunes:duration>3597</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[5825189c-dcf9-11e6-a5a2-3765693ebff5]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5926659703.mp3" length="54443781" type="audio/mpeg" />
@ -135,7 +364,7 @@
<itunes:summary>
<![CDATA[While all eyes in Washington remain focused on the Russia investigation, a Republican firm forgot to secure its invasive personal data on 198 million American voters. This week on Intercepted: We speak to radical librarian Alison Macrina of the Library Freedom Project about the fight against digital surveillance. Sam Biddle gives an update on attacks on U.S. voting systems. And, we speak with one of the rising stars of the “dirtbag left,” Felix Biederman of Chapo Trap House.]]>
</itunes:summary>
<itunes:duration>3345</itunes:duration>
<itunes:duration>3540</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[581dd44c-dcf9-11e6-a5a2-03edebf2031b]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL7980248897.mp3" length="53535555" type="audio/mpeg" />
@ -150,7 +379,7 @@
<itunes:summary>
<![CDATA[Donald Trump has a great affinity for strongmen and for unquestioned loyalty of those who work for him. This week on Intercepted: Trumps besties in Saudi Arabia convinced him that Qatar is the premiere Arab nation sponsoring terrorism. Amnesty Internationals Sherine Tadros and al Jazeeras Mehdi Hasan analyze the hypocrisy-laden, bizarre crisis. Jeremy discusses the prosecution of an alleged NSA leaker. MSNBCs Chris Hayes talks Russia, Trump, the media and his new book A Colony in a Nation. DJ Spooky imagines a Trump-inspired mash-up of Dantes Inferno and Disco Inferno.]]>
</itunes:summary>
<itunes:duration>4151</itunes:duration>
<itunes:duration>4346</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[5815ed86-dcf9-11e6-a5a2-ab3d4ad4b944]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL2441335022.mp3?updated=1497422014" length="66430432" type="audio/mpeg" />
@ -165,7 +394,7 @@
<itunes:summary>
<![CDATA[The Green Partys Jill Stein has been widely attacked by Democrats simply for running for president. Some blame her for Hillary Clintons loss. This week, Stein strikes back at her critics and reveals the story behind the infamous Moscow dinner where she was seated with Vladimir Putin and Gen. Michael Flynn. The Intercepts DC bureau chief Ryan Grim digs into the contents of a newly published top secret NSA document outlining alleged Russian cyberattacks against software companies that service U.S. elections. And singer-songwriter Damien Jurado performs.]]>
</itunes:summary>
<itunes:duration>3498</itunes:duration>
<itunes:duration>3693</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[580e21d2-dcf9-11e6-a5a2-53fae963a8d5]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5889277506.mp3?updated=1496817965" length="55977273" type="audio/mpeg" />
@ -180,7 +409,7 @@
<itunes:summary>
<![CDATA[This week, the scandal spotlight shines on Trumps influential (and strangely quiet) son-in-law. We talk to national security correspondent Spencer Ackerman of The Daily Beast about Jared Kushners alleged meetings with Russian officials to establish back channel communications. Organizer and scholar Mariame Kaba offers a peoples history of prisons in the US and the politicians—both Democrats and Republicans—who have made them what they are today. And we hear an incredible rendition of “The Partisan” from composers and musicians Leo Heiblum of Mexico and Tenzin Choegyal of Tibet.&nbsp;]]>
</itunes:summary>
<itunes:duration>3567</itunes:duration>
<itunes:duration>3762</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[5807254e-dcf9-11e6-a5a2-cb45327dca79]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3830941587.mp3" length="57086537" type="audio/mpeg" />
@ -195,7 +424,7 @@
<itunes:summary>
<![CDATA[This week, Donald Trump stood in a sea of tyrants and joined in a bizarre group petting of a glowing white orb. Professor Asad AbuKhalil dissects Trumps summit in Saudi Arabia and the role Trumps friends in the Middle East play in fueling such horrors as the attack on Manchester. The Intercepts new DC bureau chief, Ryan Grim, and national security reporter Matthew Cole discuss Gen. Michael Flynn and whether anyone in the Trump administration realizes how insane their boss is. And Steve Earle performs live.]]>
</itunes:summary>
<itunes:duration>4003</itunes:duration>
<itunes:duration>4198</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57ffd3e8-dcf9-11e6-a5a2-17ed73ff2f09]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3575958410.mp3" length="64048065" type="audio/mpeg" />
@ -210,7 +439,7 @@
<itunes:summary>
<![CDATA[Donald Trump is spectacularly bad at being president. This week on Intercepted, investigative journalist Marcy Wheeler and The Intercepts Glenn Greenwald analyze the latest insanity emanating from the White House. Pulitzer Prize-winning journalist Tim Weiner and Intercept writer Trevor Aaronson discuss the firing of James Comey and debate his FBI legacy. And Palestinian author and journalist Rula Jebreal explains why President Trump is going to Saudi Arabia and Israel on his first international trip.]]>
</itunes:summary>
<itunes:duration>3666</itunes:duration>
<itunes:duration>3861</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57f826de-dcf9-11e6-a5a2-ff41a0b1698e]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3660243774.mp3" length="58666422" type="audio/mpeg" />
@ -225,7 +454,7 @@
<itunes:summary>
<![CDATA[Donald Trumps complicated relationship with FBI Director James Comey came to a shocking conclusion in Tuesday nights episode of American shitshow. Glenn Greenwald analyzes Comeys firing. Next week, Chelsea Manning will be freed from prison. We hear exclusive audio from her trial and talk to journalist Alexa OBrien. And French civil liberties activist Yasser Louati says despite her defeat in the presidential election, many of Marine Le Pens ideas are already embedded in mainstream French politics. And a premiere track from hip-hop artists MC Sole and DJ Pain 1.]]>
</itunes:summary>
<itunes:duration>4002</itunes:duration>
<itunes:duration>4197</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57ef98d4-dcf9-11e6-a5a2-374c19bb24e7]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5670631624.mp3" length="64045557" type="audio/mpeg" />
@ -255,7 +484,7 @@
<itunes:summary>
<![CDATA[Wikileaks founder Julian Assange hits back at Trumps CIA director Mike Pompeo after Pompeo accused Wikileaks of being a “hostile non-state intelligence agency.” In a wide-ranging interview, Assange discusses the allegations Wikileaks was abetted by Russian intelligence in its publication of DNC emails, and the new-found admiration for him by FOX News and Donald Trump. Also, why Assange believes he and Hillary Clinton may get along if they ever met in person. And we premiere an unreleased song by Tom Morello of Rage Against the Machine.]]>
</itunes:summary>
<itunes:duration>3706</itunes:duration>
<itunes:duration>3901</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57e86f28-dcf9-11e6-a5a2-3f3b6bf611af]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5660744294.mp3" length="59298377" type="audio/mpeg" />
@ -270,7 +499,7 @@
<itunes:summary>
<![CDATA[Nothing brings warmongers, hawks and elites from both parties closer than a cruise missile strike. This weeks Intercepted will piss off Assad supporters and the Democrats and Republicans fawning over Trumps newest war. Former Congressman Dennis Kucinich questions the official story on the chemical weapons attack. Murtaza Hussain on what Assad gains by using chemical weapons. And, Maher Arar is a Syrian-born Canadian engineer who was kidnapped at JFK airport by US operatives after 9/11 and rendered to Syria and tortured by Assads agents. Arar says he opposes Assad and US intervention. All that and a bucket of media stupidity to celebrate beautiful missiles.]]>
</itunes:summary>
<itunes:duration>3569</itunes:duration>
<itunes:duration>3764</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57e131d6-dcf9-11e6-a5a2-efc4038c6546]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL8700626063.mp3" length="57118720" type="audio/mpeg" />
@ -285,7 +514,7 @@
<itunes:summary>
<![CDATA[Erik Prince—the most infamous mercenary in modern U.S. history—is Trumps secret emissary. This week, an exclusive interview with Rep. Jan Schakowsky, who has fought a decades-long battle against Prince. Tavis Smiley talks about the “Santa Claus-ification” of Dr. Martin Luther King Jr. on the 50th anniversary of Kings militant speech against the Vietnam War. Rep. Barbara Lee reflects on her own historic anti-war speech, delivered three days after 9/11. And Vice President Pence, who cant be alone in a room with a woman who is not his wife, goes Psycho.<br><br><em>Please take a moment to fill out Panoply's survey about the shows you listen to, love, and what else you'd like to hear: </em><a href="http://survey.panoply.fm"><em>survey.panoply.fm</em></a><em>.&nbsp; Many thanks!</em>]]>
</itunes:summary>
<itunes:duration>3428</itunes:duration>
<itunes:duration>3623</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57da1dce-dcf9-11e6-a5a2-2fa5756ae4a7]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL7737191155.mp3?updated=1491374970" length="54858396" type="audio/mpeg" />
@ -300,7 +529,7 @@
<itunes:summary>
<![CDATA[Donald Trump officially rejects climate change and unofficially declares war on planet Earth. Naomi Klein takes us on a terrifying journey into Trumps real life version of The Purge. Boots Riley of The Coup discusses Trump and hip hop and performs. Murtaza Hussain talks about the US bombings in Iraq and Syria that have killed 1,000 civilians in one month. And, we talk to the developer of an app that tracks US drone strikes that Apple has censored 13 times.]]>
</itunes:summary>
<itunes:duration>3317</itunes:duration>
<itunes:duration>3512</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57d2f170-dcf9-11e6-a5a2-5fc4825a8119]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL9529758061.mp3" length="53079980" type="audio/mpeg" />
@ -315,7 +544,7 @@
<itunes:summary>
<![CDATA[Donald Trump has not started any new wars… yet. But his administration is pouring gasoline on several initiated by his predecessors. This week on Intercepted: US forces are deploying in Syria, as drone strikes expand in Yemen. And Russia and Iran loom over everything. We talk to veteran war correspondents Anand Gopal and Iona Craig. Glenn Greenwald analyzes James Comeys testimony on Capitol Hill and exposes a major lie spread about Edward Snowden. Actor William Camp “stars” in the real life story of the spy who became “the Socrates of the NSA.”]]>
</itunes:summary>
<itunes:duration>3488</itunes:duration>
<itunes:duration>3683</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57cc3f10-dcf9-11e6-a5a2-4fe59e09c4e9]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL9134059577.mp3" length="55811761" type="audio/mpeg" />
@ -330,7 +559,7 @@
<itunes:summary>
<![CDATA[This week, Intercepted is live from the SXSW Festival in Austin. Edward Snowden joins us via video feed from Moscow. He discusses Trumps allegations of Obamas wiretapping, analyzes some of the CIAs hacking capabilities, and blasts critics who accuse him of being a Russian agent. And we talk to Libyan-American hip hop artist Kayem, who was forced to keep a low profile the past several years after multiple detentions and visits from the FBI. He shares some verses with Intercepted.]]>
</itunes:summary>
<itunes:duration>3136</itunes:duration>
<itunes:duration>3331</itunes:duration>
<itunes:explicit>no</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57c55fa6-dcf9-11e6-a5a2-1f24485c4305]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3645242256.mp3" length="50191046" type="audio/mpeg" />
@ -345,7 +574,7 @@
<itunes:summary>
<![CDATA[The Notorious B.I.G. famously alleged that federal agents were mad because he was flagrant. Trump also believes he has beef with the Feds, accusing Obama of tapping his phones. The Intercepts Matthew Cole and journalist Marcy Wheeler dissect the accusations and the (curious) denials. Sam Biddle and Josh Begley explain what the CIA hacking docs published by Wikileaks say about our “smart” TVs and phones. Journalist Aura Bogado confronts Trumps assault on undocumented immigrants. Punk band Anti-Flag performs. Plus, Trump “stars” in a scene from Goodfellas. Can he get out of Mar-a-Lago alive?]]>
</itunes:summary>
<itunes:duration>3987</itunes:duration>
<itunes:duration>4182</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57be3b36-dcf9-11e6-a5a2-07d3f1f2cb5f]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3152884319.mp3" length="63798125" type="audio/mpeg" />
@ -360,7 +589,7 @@
<itunes:summary>
<![CDATA[Ex-CIA analyst Nada Bakos and former FBI agent Clint Watts explain how Trumps administration could use “alternative intelligence” to justify dangerous military actions. Shane Bauer of Mother Jones breaks down the connections between immigration raids and soaring private prison profits. Plus the world premiere of a song by the Iraqi-Canadian hip hop artist Narcy. We bet you never thought youd hear Steve Bannons name rapped in autotune.]]>
</itunes:summary>
<itunes:duration>4001</itunes:duration>
<itunes:duration>4196</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57b74678-dcf9-11e6-a5a2-4fbc5ae0d0cf]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5707421213.mp3" length="64025913" type="audio/mpeg" />
@ -375,7 +604,7 @@
<itunes:summary>
<![CDATA[New York Times investigative reporter James Risen breaks down Trumps declaration that journalists are the enemy and analyzes Trumps royal court. ACLU lawyer Chase Strangio and former New England Patriots star Donté Stallworth talk about the war on the transgender community and the rising resistance of pro athletes. Sam Biddle exposes the Trump-connected firm that helped the NSA spy on the world and actor Wallace Shawn stars as an NSA operative who is worried about adversaries spying on his luncheons. Plus music from Anohni.]]>
</itunes:summary>
<itunes:duration>4014</itunes:duration>
<itunes:duration>4209</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57b018d0-dcf9-11e6-a5a2-e736fa72fede]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL3910130795.mp3" length="64229877" type="audio/mpeg" />
@ -390,7 +619,7 @@
<itunes:summary>
<![CDATA[The first contestant in Donald Trumps reality administration has left the West Wing. This week, Glenn Greenwald offers some provocative pushback on the Russia fear-mongering surrounding Gen. Michael Flynns resignation (or firing). Naomi Klein walks the dark aisles of the Trump family department store. Former Congresswoman Liz Holtzman, a key figure in the impeachment of Richard Nixon, explains how impeachment actually works. And Hina Shamsi of the ACLU recounts her interrogation at the border. Plus a performance from Jedi Mind Tricks.&nbsp;]]>
</itunes:summary>
<itunes:duration>3663</itunes:duration>
<itunes:duration>3858</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57a87fd0-dcf9-11e6-a5a2-af85a8453351]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL5616910839.mp3" length="58608326" type="audio/mpeg" />
@ -405,7 +634,7 @@
<itunes:summary>
<![CDATA[This week, investigative reporter Allan Nairn breaks down Trump's relationship with the CIA and the killer assembly of neocons and right-wing conspiracists running the U.S. war machine. Princeton professor Keeanga Yamahtta-Taylor dismantles Obama's problematic legacy and offers strategic advice for resisting Trump. The Intercept's own distinguished alt-historian, Jon Schwarz, offers a lesson on the origins of presidential executive orders. And Kimya Dawson gives a raw performance of a new song about racism and the police state.]]>
</itunes:summary>
<itunes:duration>3557</itunes:duration>
<itunes:duration>3752</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57a133b0-dcf9-11e6-a5a2-9f64a29807d9]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL7005027452.mp3" length="56920189" type="audio/mpeg" />
@ -420,7 +649,7 @@
<itunes:summary>
<![CDATA[Donald Trump is signing executive orders like autographed pictures. This week on Intercepted: Two former senior FBI agents blast the “Muslim ban” and Trumps campaign to make torture great again. Constitutional rights lawyers dissect the (il)legalities of Trumps orders. Rep. Barbara Lee confronts the president's terrifying approach to government.&nbsp; New secret documents reveal how Trump could resurrect J. Edgar Hoovers legacy. Brother Ali freestyles a verse, and Peter Sarsgaard stars in the bizarre true story of an NSA operative with vacation tips for deploying to Guantanamo.&nbsp;]]>
</itunes:summary>
<itunes:duration>3163</itunes:duration>
<itunes:duration>3358</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57996266-dcf9-11e6-a5a2-4ff22525cee4]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL4823102330.mp3?updated=1485937504" length="50611513" type="audio/mpeg" />
@ -435,7 +664,7 @@
<itunes:summary>
<![CDATA[The clock struck thirteen on January 20, Donald Trump is the president of the United States and episode one of Intercepted is here. Intercept co-founder Glenn Greenwald and editor-in-chief Betsy Reed join Jeremy Scahill for a discussion on the crazy apocalyptic present. They break down Trumps attacks on the media, that insane speech he gave at the CIA and the state of the Democratic party. Naomi Klein sends in a dispatch from the Womens March on Washington. Jeremy goes deep into the secretive world of Seymour Hershs kitchen, and shoots the shit with the legendary Pulitzer Prize-winning journalist about why he calls Trump a “circuit breaker." And we hear a spoken word performance from hip-hop artist Immortal Technique.&nbsp;]]>
</itunes:summary>
<itunes:duration>3238</itunes:duration>
<itunes:duration>3433</itunes:duration>
<itunes:explicit>yes</itunes:explicit>
<guid isPermaLink="false"><![CDATA[57913302-dcf9-11e6-a5a2-87c6a559fb64]]></guid>
<enclosure url="http://traffic.megaphone.fm/FL1844876464.mp3" length="51822341" type="audio/mpeg" />

View File

@ -7,8 +7,8 @@
<generator>Feeder 2.5.12(2294); Mac OS X Version 10.12.1 (Build 16B2657) http://reinventedsoftware.com/feeder/</generator>
<docs>http://blogs.law.harvard.edu/tech/rss</docs>
<language>en</language>
<pubDate>Tue, 07 Nov 2017 18:47:35 -0800</pubDate>
<lastBuildDate>Tue, 07 Nov 2017 18:47:35 -0800</lastBuildDate>
<pubDate>Tue, 16 Jan 2018 20:28:23 -0800</pubDate>
<lastBuildDate>Tue, 16 Jan 2018 20:28:23 -0800</lastBuildDate>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:image href="http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg" />
@ -17,6 +17,188 @@
<itunes:block>no</itunes:block>
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="self" type="application/rss+xml" href="http://feeds.feedburner.com/linuxunplugged" /><feedburner:info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" uri="linuxunplugged" /><atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" rel="hub" href="http://pubsubhubbub.appspot.com/" /><media:copyright>Copyright Jupiter Broadcasting</media:copyright><media:thumbnail url="http://www.jupiterbroadcasting.com/images/LASUN-Badge1400.jpg" /><media:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</media:keywords><media:category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">Technology/Tech News</media:category><itunes:owner><itunes:email>chris@jupiterbroadcasting.com</itunes:email><itunes:name>Jupiter Broadcasting</itunes:name></itunes:owner><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords><itunes:subtitle>Linux Action Show, with no rules.</itunes:subtitle><itunes:summary>An open show powered by community LINUX Unplugged takes the best attributes of open collaboration and focuses them into a weekly lifestyle show about Linux.</itunes:summary><itunes:category text="Technology"><itunes:category text="Tech News" /></itunes:category><image><link>http://www.jupiterbroadcasting.com/show/linuxun/</link><url>http://www.jupiterbroadcasting.com/images/lupbadge.jpg</url><title>LINUX Unplugged</title></image><item>
<title>The Secret to Future Linux Success | LUP 232</title>
<link>http://www.jupiterbroadcasting.com/121542/the-secret-to-future-linux-success-lup-232/</link>
<description><![CDATA[<p>A big week of community updates, events & news, including great news for Plasma Desktop users, MATE users & Wayland fans.</p>
<p>Then Barton George from Dell joins us to discuss the new XPS 13s shipping Ubuntu, where Linux could see its next big success & more!</p>]]></description>
<pubDate>Tue, 16 Jan 2018 20:28:02 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0232.mp3" length="50481539" type="audio/mpeg" />
<guid isPermaLink="false">903D4C53-092B-4131-BA52-08DBCDE55E3E</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>A big week of community updates, events &amp; news, including great news for Plasma Desktop users, MATE users &amp; Wayland fans. Then Barton George from Dell joins us to discuss the new XPS 13s shipping Ubuntu, where Linux could see its next big success &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[A big week of community updates, events & news, including great news for Plasma Desktop users, MATE users & Wayland fans.
Then Barton George from Dell joins us to discuss the new XPS 13s shipping Ubuntu, where Linux could see its next big success & more!]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:43:53</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2018/01/lup-0232-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0232.mp3" fileSize="50481539" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Most Expensive Linux Distro Ever | LUP 231</title>
<link>http://www.jupiterbroadcasting.com/121257/most-expensive-linux-distro-ever-lup-231/</link>
<description><![CDATA[<p>We slay the Gentoo challenge monster & give you our first take of the most expensive Linux distro weve ever tried. What does nearly $100 of Linux get you? We find out!</p>
<p>Plus tons of community news, the perfect Linux workstation coming soon & more!</p>]]></description>
<pubDate>Tue, 09 Jan 2018 20:33:30 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0231.mp3" length="47946199" type="audio/mpeg" />
<guid isPermaLink="false">2B587F89-B71C-4B5C-8DFF-EFF1106E8B81</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>We slay the Gentoo challenge monster &amp; give you our first take of the most expensive Linux distro weve ever tried. What does nearly $100 of Linux get you? We find out!
Plus tons of community news, the perfect Linux workstation coming soon &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[We slay the Gentoo challenge monster & give you our first take of the most expensive Linux distro weve ever tried. What does nearly $100 of Linux get you? We find out!
Plus tons of community news, the perfect Linux workstation coming soon & more!]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:38:36</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2018/01/lup-0231-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0231.mp3" fileSize="47946199" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Invest In Popcorn | LUP 230</title>
<link>http://www.jupiterbroadcasting.com/121092/invest-in-popcorn-lup-230/</link>
<description><![CDATA[Wes & the Beard kick Chris out to share their top tips for starting 2018 out right, plus a holiday surprise from Linux Journal, a new device for Googles Fuchsia & an unfortunate new flaw in a processor near you.]]></description>
<pubDate>Tue, 02 Jan 2018 23:18:25 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0230.mp3" length="27993036" type="audio/mpeg" />
<guid isPermaLink="false">AFC10D98-612B-4C12-BB5C-06A09B09681C</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>Wes &amp; the Beard kick Chris out to share their top tips for starting 2018 out right, plus a holiday surprise from Linux Journal, a new device for Googles Fuchsia &amp; an unfortunate new flaw in a processor near you.</itunes:subtitle>
<itunes:summary><![CDATA[Wes & the Beard kick Chris out to share their top tips for starting 2018 out right, plus a holiday surprise from Linux Journal, a new device for Googles Fuchsia & an unfortunate new flaw in a processor near you.]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>57:02</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2018/01/lup-0230-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0230.mp3" fileSize="27993036" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Taste of Linux 2017 | LUP 229</title>
<link>http://www.jupiterbroadcasting.com/120922/taste-of-linux-2017-lup-229/</link>
<description><![CDATA[We break from the unformat of the show for a special holiday chat about the top moments in the world of Linux this year that impacted us the most.]]></description>
<pubDate>Tue, 26 Dec 2017 18:29:58 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0229.mp3" length="41841905" type="audio/mpeg" />
<guid isPermaLink="false">FE1EC5F7-9DCF-4365-B5A6-096FAE104908</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>We break from the unformat of the show for a special holiday chat about the top moments in the world of Linux this year that impacted us the most.</itunes:subtitle>
<itunes:summary><![CDATA[We break from the unformat of the show for a special holiday chat about the top moments in the world of Linux this year that impacted us the most.]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:25:53</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/12/lup-0229-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0229.mp3" fileSize="41841905" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>rm -rf 2017 | LUP 228</title>
<link>http://www.jupiterbroadcasting.com/120772/rm-rf-2017-lup-228/</link>
<description><![CDATA[<p>We debate the best distros of 2017, get into some community news, and a bcachefs and Gentoo challenge update & also learn a bit about Canonicals new Multipass project.</p>
<p>Plus a few Linux commands that are guaranteed to destroy your install.</p>]]></description>
<pubDate>Tue, 19 Dec 2017 20:04:09 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0228.mp3" length="37338604" type="audio/mpeg" />
<guid isPermaLink="false">083E11EA-D405-4C53-AB09-6C4FC13DDD81</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>We debate the best distros of 2017, get into some community news, and a bcachefs and Gentoo challenge update &amp; also learn a bit about Canonicals new Multipass project.
Plus a few Linux commands that are guaranteed to destroy your install.</itunes:subtitle>
<itunes:summary><![CDATA[We debate the best distros of 2017, get into some community news, and a bcachefs and Gentoo challenge update & also learn a bit about Canonicals new Multipass project.
Plus a few Linux commands that are guaranteed to destroy your install.]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:16:30</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/12/lup-0228-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0228.mp3" fileSize="37338604" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Peer Pressure | LUP 227</title>
<link>http://www.jupiterbroadcasting.com/120622/peer-pressure-lup-227/</link>
<description><![CDATA[<p>Its time to replace Patreon, YouTube, Twitter/Facebook & all the other centralized platforms of the web. But can open source answer the call? This week we look at a few projects that could replace todays information silos if Linux users just step up.</p>
<p>Plus community news, some big updates & a lot more!</p>]]></description>
<pubDate>Thu, 14 Dec 2017 01:31:44 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0227.mp3" length="45057056" type="audio/mpeg" />
<guid isPermaLink="false">0533598D-EDA5-4757-ACF0-12F3142C9713</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>Its time to replace Patreon, YouTube, Twitter/Facebook &amp; other centralized platforms of the web. But can open source answer the call? This week we look at a few projects that could replace todays information silos if Linux users just step up &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[Its time to replace Patreon, YouTube, Twitter/Facebook & all the other centralized platforms of the web. But can open source answer the call? This week we look at a few projects that could replace todays information silos if Linux users just step up.
Plus community news, some big updates & a lot more!]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:32:35</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/12/lup-0227-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0227.mp3" fileSize="45057056" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Bitcoin for Linux Users | LUP 226</title>
<link>http://www.jupiterbroadcasting.com/120447/bitcoin-for-linux-users-lup-226/</link>
<description><![CDATA[Why Bitcoin is the next Linux, the Gentoo Challenge is in full swing, and we catch you up on the latest community news, a throwback app pick & more!]]></description>
<pubDate>Tue, 05 Dec 2017 20:58:00 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0226.mp3" length="40776318" type="audio/mpeg" />
<guid isPermaLink="false">E457EE68-10F2-4AE7-929B-8C6F93342988</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>Why Bitcoin is the next Linux, the Gentoo Challenge is in full swing, and we catch you up on the latest community news, a throwback app pick &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[Why Bitcoin is the next Linux, the Gentoo Challenge is in full swing, and we catch you up on the latest community news, a throwback app pick & more!]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:23:40</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/12/lup-0226-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0226.mp3" fileSize="40776318" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Hacking the Community | LUP 225</title>
<link>http://www.jupiterbroadcasting.com/120287/hacking-the-community-lup-225/</link>
<description><![CDATA[<p>Red Hat, Amazon, Facebook, Google, IBM, and others come together to push common sense GPL enforcement & a whole batch of community news.</p>
<p>Plus we call out the Register, DRMs dirty little secret & how Linux users can make a difference.</p>]]></description>
<pubDate>Wed, 29 Nov 2017 00:17:08 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0225.mp3" length="48188615" type="audio/mpeg" />
<guid isPermaLink="false">30BE2BE8-E0B0-4DFD-A1D2-EAE0F4DA4BBB</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>Red Hat, Amazon, Facebook, Google, IBM, and others come together to push common sense GPL enforcement &amp; a whole batch of community news.
Plus we call out the Register, DRMs dirty little secret &amp; how Linux users can make a difference.</itunes:subtitle>
<itunes:summary><![CDATA[Red Hat, Amazon, Facebook, Google, IBM, and others come together to push common sense GPL enforcement & a whole batch of community news.
Plus we call out the Register, DRMs dirty little secret & how Linux users can make a difference.]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:39:07</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/11/lup-0225-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0225.mp3" fileSize="48188615" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>No Escape from Google | LUP 224</title>
<link>http://www.jupiterbroadcasting.com/120086/no-escape-from-google-lup-224/</link>
<description><![CDATA[<p>Google gets caught red handed, we find lots of goodies in the new Linux kernel & we have three great new app picks this week.</p>
<p>But the meat of the show is Lynis a tool to audit your Linux box, create reports & teach you how to better secure your system. </p>
<p>Plus we officially lay the groundwork for the Gentoo Challenge.</p>]]></description>
<pubDate>Wed, 22 Nov 2017 04:14:57 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0224.mp3" length="40944756" type="audio/mpeg" />
<guid isPermaLink="false">C2E924CC-9F00-468B-9D33-AF163FE2D58F</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>Google gets caught red handed and goodies in the new Linux kernel. But the meat of the show is Lynis a tool to audit your Linux box, create reports &amp; teach you how to better secure your system.
Plus the groundwork for the Gentoo Challenge &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[Google gets caught red handed, we find lots of goodies in the new Linux kernel & we have three great new app picks this week.
But the meat of the show is Lynis a tool to audit your Linux box, create reports & teach you how to better secure your system.
Plus we officially lay the groundwork for the Gentoo Challenge.]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:24:01</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/11/lup-0224-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0224.mp3" fileSize="40944756" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>Fedoras New Trick | LUP 223</title>
<link>http://www.jupiterbroadcasting.com/119866/fedoras-new-trick-lup-223/</link>
<description><![CDATA[<p>A new version of Fedora hits the web and we share our thoughts & chat with a member of the project, Noah joins us to answer your live calls & were all excited about Firefoxs new quantum release.</p>
<p>Plus Gnome 4s ambitious goals, a new Linux Kernel that really matters, OpenShot woes & more!</p>]]></description>
<pubDate>Wed, 15 Nov 2017 02:37:23 -0800</pubDate>
<enclosure url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0223.mp3" length="53118235" type="audio/mpeg" />
<guid isPermaLink="false">205F5F07-AB94-4857-AE6D-6B3EB5C0E06C</guid>
<itunes:author>Jupiter Broadcasting</itunes:author>
<itunes:subtitle>A new version of Fedora hits the web and we share our thoughts &amp; chat with a member of the project, Noah joins us &amp; were all excited about Firefox quantum.
Plus Gnome 4s ambitious goals, a new Linux Kernel that really matters, OpenShot woes &amp; more!</itunes:subtitle>
<itunes:summary><![CDATA[A new version of Fedora hits the web and we share our thoughts & chat with a member of the project, Noah joins us to answer your live calls & were all excited about Firefoxs new quantum release.
Plus Gnome 4s ambitious goals, a new Linux Kernel that really matters, OpenShot woes & more!]]></itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:duration>1:49:23</itunes:duration>
<media:thumbnail url="http://www.jupiterbroadcasting.com/wp-content/uploads/2017/11/lup-0223-v.jpg" />
<author>chris@jupiterbroadcasting.com (Jupiter Broadcasting)</author><media:content url="http://www.podtrac.com/pts/redirect.mp3/traffic.libsyn.com/jnite/lup-0223.mp3" fileSize="53118235" type="audio/mpeg" /><itunes:keywords>Linux,KDE,Arch,Ubuntu,Unity,lifestyle,open,foss,libre,Chrome,Google,Android,Games,Steam,Gnome,GTK,Qt,Jupiter,Broadcasting,Linux,Action,Show</itunes:keywords></item>
<item>
<title>A Community Divided | LUP 222</title>
<link>http://www.jupiterbroadcasting.com/119691/a-community-divided-lup-222/</link>
<description><![CDATA[<p>Community news & app picks this week before we get into a bizarre story that could rip up parts of the open source community.</p>

View File

@ -0,0 +1,370 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:art19="https://art19.com/xmlns/rss-extensions/1.0">
<channel>
<title>Steal the Stars</title>
<description>
<![CDATA[<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars is a gripping noir science fiction thriller in 14 episodes: Forbidden love, a crashed UFO, an alien body, and an impossible heist unlike any ever attempted - scripted by Mac Rogers, the award-winning playwright and writer of the multi-million download The Message and LifeAfter.</p>]]>
</description>
<managingEditor>podcasts@macmillan.com</managingEditor>
<copyright>© Gideon Media</copyright>
<atom:link href="https://rss.art19.com/steal-the-stars" rel="self" type="application/rss+xml"/>
<link>http://tor-labs.com/</link>
<itunes:owner>
<itunes:email>podcasts@macmillan.com</itunes:email>
</itunes:owner>
<itunes:author>Tor Labs / Gideon Media</itunes:author>
<itunes:summary>
<![CDATA[<p>The first audio drama from Tor Labs and Gideon Media, Steal the Stars is a gripping noir science fiction thriller in 14 episodes: Forbidden love, a crashed UFO, an alien body, and an impossible heist unlike any ever attempted - scripted by Mac Rogers, the award-winning playwright and writer of the multi-million download The Message and LifeAfter.</p>]]>
</itunes:summary>
<language>en</language>
<itunes:explicit>yes</itunes:explicit>
<itunes:category text="Arts">
<itunes:category text="Performing Arts"/>
</itunes:category>
<itunes:type>episodic</itunes:type>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<image>
<url>https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg</url>
<link>http://tor-labs.com/</link>
<title>Steal the Stars</title>
</image>
<item>
<title>14: As Fierce, As Colossal, As All-Consuming</title>
<description>
<![CDATA[<p>In an epic final showdown in the Texas desert - as Sierra closes in from all sides - Dak and Matt finally learn the truth about Moss.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS).</p>]]>
</description>
<itunes:title>14: As Fierce, As Colossal, As All-Consuming</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>14</itunes:episode>
<itunes:summary>In an epic final showdown in the Texas desert - as Sierra closes in from all sides - Dak and Matt finally learn the truth about Moss. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS).</itunes:summary>
<content:encoded>
<![CDATA[<p>In an epic final showdown in the Texas desert - as Sierra closes in from all sides - Dak and Matt finally learn the truth about Moss.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/Ck8iDIER5Fpmc9Rx4ICwxJAvcYGaJWLRNYRf8IAZu2c</guid>
<pubDate>Wed, 01 Nov 2017 03:31:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:43:50</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/b9a3e534-1070-4e21-91f2-e97d35dc9bf3.mp3" type="audio/mpeg" length="40163369"/>
</item>
<item>
<title>13: Matt-25</title>
<description>
<![CDATA[<p>Dak and Matt hide out for the night with Matt's ex-girlfriend Teresa, leading Dak to an unexpected moment of connection... and another unexpected moment that threatens to ruin everything.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS) and Parcast.</p>]]>
</description>
<itunes:title>13: Matt-25</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>13</itunes:episode>
<itunes:summary>Dak and Matt hide out for the night with Matt's ex-girlfriend Teresa, leading Dak to an unexpected moment of connection... and another unexpected moment that threatens to ruin everything. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS) and Parcast.</itunes:summary>
<content:encoded>
<![CDATA[<p>Dak and Matt hide out for the night with Matt's ex-girlfriend Teresa, leading Dak to an unexpected moment of connection... and another unexpected moment that threatens to ruin everything.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace (Squarespace.com offer code STARS) and Parcast.</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/_r5K_8_5HFIXQ1xhv7RHvMvYLOo_0JMnYWHNTaABxuQ</guid>
<pubDate>Wed, 25 Oct 2017 03:35:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:36:14</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/a72503e8-a984-4911-a1dc-e8dcc1a10dfd.mp3" type="audio/mpeg" length="31192711"/>
</item>
<item>
<title>12: All That Sky</title>
<description>
<![CDATA[<p>Dak and Matt are finally on the road with their extraterrestrial contraband, but Sierra is hot on their heels. They're finally forced to take refuge in the last place Dak wants to go.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace and Audible.</p>]]>
</description>
<itunes:title>12: All That Sky</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>12</itunes:episode>
<itunes:summary>Dak and Matt are finally on the road with their extraterrestrial contraband, but Sierra is hot on their heels. They're finally forced to take refuge in the last place Dak wants to go. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Squarespace and Audible.</itunes:summary>
<content:encoded>
<![CDATA[<p>Dak and Matt are finally on the road with their extraterrestrial contraband, but Sierra is hot on their heels. They're finally forced to take refuge in the last place Dak wants to go.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Squarespace and Audible.</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/U1ByYJR2Eq124QgYFZ6FH8Pa1vjpo_XeXfr1meRL6hU</guid>
<pubDate>Wed, 18 Oct 2017 03:31:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:34:34</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/cea5f3e8-f82c-439d-89c8-6cbd402b9df9.mp3" type="audio/mpeg" length="27200365"/>
</item>
<item>
<title>11: Checkpoints</title>
<description>
<![CDATA[<p>Getting Moss's body out of Hangar 11 is one thing. Getting it out of Quill Marine is quite another. And there's a lot of checkpoints - and angry people - standing between Dak &amp; Mat and freedom.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Lore on Amazon.</p>]]>
</description>
<itunes:title>11: Checkpoints</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>11</itunes:episode>
<itunes:summary>Getting Moss's body out of Hangar 11 is one thing. Getting it out of Quill Marine is quite another. And there's a lot of checkpoints - and angry people - standing between Dak &amp;amp; Mat and freedom. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Lore on Amazon.</itunes:summary>
<content:encoded>
<![CDATA[<p>Getting Moss's body out of Hangar 11 is one thing. Getting it out of Quill Marine is quite another. And there's a lot of checkpoints - and angry people - standing between Dak &amp; Mat and freedom.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Lore on Amazon.</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/7-k-JxtB5xrmn6ZuXF7Xz0VTgO_Zm580QQVpQJ2OCq4</guid>
<pubDate>Wed, 11 Oct 2017 03:30:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:22:42</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/3a6e5f73-0b8f-435b-a7c3-b569dd9cedf6.mp3" type="audio/mpeg" length="19402083"/>
</item>
<item>
<title>10: Protocol</title>
<description>
<![CDATA[<p>By the end of this day, Dak and Matt will either be dead... or they'll have just pulled off the most incredible heist of all time.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p>]]>
</description>
<itunes:title>10: Protocol</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>10</itunes:episode>
<itunes:summary>By the end of this day, Dak and Matt will either be dead... or they'll have just pulled off the most incredible heist of all time. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</itunes:summary>
<content:encoded>
<![CDATA[<p>By the end of this day, Dak and Matt will either be dead... or they'll have just pulled off the most incredible heist of all time.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/zPXgIdxCbanfZNot2NoqCtuA8p6TvneH8uRWjipDeaU</guid>
<pubDate>Wed, 04 Oct 2017 03:30:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:25:24</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/19d8cc64-22cf-4d00-a67f-b55f05d0443b.mp3" type="audio/mpeg" length="21997609"/>
</item>
<item>
<title>9: The Real Stuff</title>
<description>
<![CDATA[<p>Dak has to take two vitally important meetings today, with two of the most powerful men in Washington, DC. And they have to go perfectly: her fate and Matt's hang in the balance.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</p>]]>
</description>
<itunes:title>9: The Real Stuff</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>9</itunes:episode>
<itunes:summary>Dak has to take two vitally important meetings today, with two of the most powerful men in Washington, DC. And they have to go perfectly: her fate and Matt's hang in the balance. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</itunes:summary>
<content:encoded>
<![CDATA[<p>Dak has to take two vitally important meetings today, with two of the most powerful men in Washington, DC. And they have to go perfectly: her fate and Matt's hang in the balance.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/UmSCXUzVugdSQDFnPvUeSuDeLH6cVhXvYXI-YOebnpg</guid>
<pubDate>Wed, 27 Sep 2017 03:30:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:28:02</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/24e08163-7f79-4144-9bd8-0b1978e082cd.mp3" type="audio/mpeg" length="23081795"/>
</item>
<item>
<title>8: The Walls of the Maze</title>
<description>
<![CDATA[<p>Dak has a whole new plan to be with Matt now, a far more dangerous one. One which will carry her across the country to start putting the pieces in place for a perfect getaway.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify and Audible (Audible.com/stealthestars).</p>]]>
</description>
<itunes:title>8: The Walls of the Maze</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>8</itunes:episode>
<itunes:summary>Dak has a whole new plan to be with Matt now, a far more dangerous one. One which will carry her across the country to start putting the pieces in place for a perfect getaway. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is sponsored by Spotify and Audible (Audible.com/stealthestars).</itunes:summary>
<content:encoded>
<![CDATA[<p>Dak has a whole new plan to be with Matt now, a far more dangerous one. One which will carry her across the country to start putting the pieces in place for a perfect getaway.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify and Audible (Audible.com/stealthestars).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/JZ3lAioyC2wxIx_HvrP_o7LgjercB6W5UsPbNsMn-ek</guid>
<pubDate>Wed, 20 Sep 2017 03:30:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:27:17</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/e8d9e9c0-f798-4283-ae7e-de5dc3cc7ef8.mp3" type="audio/mpeg" length="22116310"/>
</item>
<item>
<title>7: Altered Voices</title>
<description>
<![CDATA[<p>As Lloyd reveals startling new details about the origin of Object E, Dak and Matt's plan is hit with one brutal setback after another.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</p>]]>
</description>
<itunes:title>7: Altered Voices</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>7</itunes:episode>
<itunes:summary>As Lloyd reveals startling new details about the origin of Object E, Dak and Matt's plan is hit with one brutal setback after another. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</itunes:summary>
<content:encoded>
<![CDATA[<p>As Lloyd reveals startling new details about the origin of Object E, Dak and Matt's plan is hit with one brutal setback after another.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Spotify, Squarespace (Squarespace.com/stars) and Leesa (Leesa.com/stars)</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/i4i_I3dr9rxfV96N0rMCUcvDIdAdwV54lTJf29ZorTs</guid>
<pubDate>Wed, 13 Sep 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:31:06</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/4183c9bc-b272-4a08-8be3-4e49895c7134.mp3" type="audio/mpeg" length="26498194"/>
</item>
<item>
<title>6: 900 Microns</title>
<description>
<![CDATA[<p>As Dak and Matt put their dangerous plan into effect - which involves stealing highly classified footage and meeting in secret with a reporter - Quill Marine gets some devastating news from Sierra.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Spotify.&nbsp;</p>]]>
</description>
<itunes:title>6: 900 Microns</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>6</itunes:episode>
<itunes:summary>As Dak and Matt put their dangerous plan into effect - which involves stealing highly classified footage and meeting in secret with a reporter - Quill Marine gets some devastating news from Sierra. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Spotify. </itunes:summary>
<content:encoded>
<![CDATA[<p>As Dak and Matt put their dangerous plan into effect - which involves stealing highly classified footage and meeting in secret with a reporter - Quill Marine gets some devastating news from Sierra.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Spotify.&nbsp;</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/9BGMWV_KT25yeSPMNF25Z0OF4-TBcZ2NYzX4znBSzZY</guid>
<pubDate>Wed, 06 Sep 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:28:00</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/844ec351-9235-4c5b-9e80-012c61924b94.mp3" type="audio/mpeg" length="25448698"/>
</item>
<item>
<title>5: Lifers</title>
<description>
<![CDATA[<p>After Dak and Patty have to carry out the worst part of their job, Dak's romance with Matt reaches another level. With no legal way to be together, they decide on a desperate plan.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Audible (<a href="http://Audible.com/stealthestars" target="_blank">Audible.com/stealthestars</a>).</p>]]>
</description>
<itunes:title>5: Lifers</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>5</itunes:episode>
<itunes:summary>After Dak and Patty have to carry out the worst part of their job, Dak's romance with Matt reaches another level. With no legal way to be together, they decide on a desperate plan. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Audible (Audible.com/stealthestars).</itunes:summary>
<content:encoded>
<![CDATA[<p>After Dak and Patty have to carry out the worst part of their job, Dak's romance with Matt reaches another level. With no legal way to be together, they decide on a desperate plan.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Audible (<a href="http://Audible.com/stealthestars" target="_blank">Audible.com/stealthestars</a>).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/P1U26uhQ4m-p9ZvKAM0aaMpXPCxKzYhYoKF8JQl5fcw</guid>
<pubDate>Wed, 30 Aug 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:28:07</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/2b33e493-1751-4705-881e-7043ba5e1d56.mp3" type="audio/mpeg" length="24593554"/>
</item>
<item>
<title>4: Power Through</title>
<description>
<![CDATA[<p>Today, Trip Haydon - the head of Sierra and the man who holds all their fates in his hand - is visiting Quill Marine. It's the ultimate test of Dak's leadership. There's no margin for even one mistake.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Spotify, Plated (Plated.com/stars, terms apply) and Leesa (Leesa.com/stars).</p>]]>
</description>
<itunes:title>4: Power Through</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>4</itunes:episode>
<itunes:summary>Today, Trip Haydon - the head of Sierra and the man who holds all their fates in his hand - is visiting Quill Marine. It's the ultimate test of Dak's leadership. There's no margin for even one mistake. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Spotify, Plated (Plated.com/stars, terms apply) and Leesa (Leesa.com/stars).</itunes:summary>
<content:encoded>
<![CDATA[<p>Today, Trip Haydon - the head of Sierra and the man who holds all their fates in his hand - is visiting Quill Marine. It's the ultimate test of Dak's leadership. There's no margin for even one mistake.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Spotify, Plated (Plated.com/stars, terms apply) and Leesa (Leesa.com/stars).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/CfyNFMaQmlDZhNV4yqiUtKMiD-YdGrgDAHRZDbqlCkk</guid>
<pubDate>Wed, 23 Aug 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:38:48</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/ea58f0ff-693e-4d00-a133-e137a098b512.mp3" type="audio/mpeg" length="32449515"/>
</item>
<item>
<title>3: Turndown Service</title>
<description>
<![CDATA[<p>When they find out the man who runs Sierra is paying them a surprise visit, Dak and Matt have to carry out a hazardous test that will either bring them closer together or kill them.</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Plated&nbsp;(<a href="http://plated.com/stars" target="_blank">Plated.com/stars</a>.&nbsp;Terms&nbsp;and conditions&nbsp;apply) and Squarespace (<a href="http://Squarespace.com" target="_blank">Squarespace.com</a>, offer code: Stars).</p>]]>
</description>
<itunes:title>3: Turndown Service</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>3</itunes:episode>
<itunes:summary>When they find out the man who runs Sierra is paying them a surprise visit, Dak and Matt have to carry out a hazardous test that will either bring them closer together or kill them.
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is sponsored by Plated (Plated.com/stars. Terms and conditions apply) and Squarespace (Squarespace.com, offer code: Stars).</itunes:summary>
<content:encoded>
<![CDATA[<p>When they find out the man who runs Sierra is paying them a surprise visit, Dak and Matt have to carry out a hazardous test that will either bring them closer together or kill them.</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Plated&nbsp;(<a href="http://plated.com/stars" target="_blank">Plated.com/stars</a>.&nbsp;Terms&nbsp;and conditions&nbsp;apply) and Squarespace (<a href="http://Squarespace.com" target="_blank">Squarespace.com</a>, offer code: Stars).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/KSQYXYJF7bgP95gjvhiI3YRSckvkLtu8FTVgfhM9lrk</guid>
<pubDate>Wed, 16 Aug 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:33:08</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/0e800c7e-4d3c-4acf-aa3f-8d1e1f508d36.mp3" type="audio/mpeg" length="28936568"/>
</item>
<item>
<title>2: Three Dogs</title>
<description>
<![CDATA[<p>As Dak and Matt try to extinguish their forbidden relationship before it starts, we meet Lloyd, a brilliant xenobiologist who's about to risk his life in a dangerous encounter with the Harp.</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Plated&nbsp;(<a href="http://Plated.com/stars" target="_blank">Plated.com/stars</a>.&nbsp;Terms&nbsp;and conditions&nbsp;apply) and Leesa (<a href="http://Leesa.com/stars" target="_blank">Leesa.com/stars</a>).</p>]]>
</description>
<itunes:title>2: Three Dogs</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>2</itunes:episode>
<itunes:summary>As Dak and Matt try to extinguish their forbidden relationship before it starts, we meet Lloyd, a brilliant xenobiologist who's about to risk his life in a dangerous encounter with the Harp.
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is sponsored by Plated (Plated.com/stars. Terms and conditions apply) and Leesa (Leesa.com/stars).</itunes:summary>
<content:encoded>
<![CDATA[<p>As Dak and Matt try to extinguish their forbidden relationship before it starts, we meet Lloyd, a brilliant xenobiologist who's about to risk his life in a dangerous encounter with the Harp.</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is sponsored by Plated&nbsp;(<a href="http://Plated.com/stars" target="_blank">Plated.com/stars</a>.&nbsp;Terms&nbsp;and conditions&nbsp;apply) and Leesa (<a href="http://Leesa.com/stars" target="_blank">Leesa.com/stars</a>).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/YSwgVQfG9gshqMg7TEpn1q7tjJVYbtM2_Y6zEvjl0Ns</guid>
<pubDate>Wed, 09 Aug 2017 03:15:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:31:20</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/752f5cc4-66ac-4254-9cf1-b1d35991069c.mp3" type="audio/mpeg" length="27205381"/>
</item>
<item>
<title>1: Warm Bodies</title>
<description>
<![CDATA[<p>Dakota Prentiss runs security at the secretive Quill Marine compound, run by private defense conglomerate Sierra. Today she's breaking in a new security staffer, Matt Salem. Which means Matt has to pass a crucial test: how he reacts to the secret at the heart of Quill Marine.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Audible (<a href="http://Audible.com/stealthestars" target="_blank">Audible.com/stealthestars</a>) and Plated (<a href="http://Plated.com/stars" target="_blank">Plated.com/stars</a>. Terms apply).</p>]]>
</description>
<itunes:title>1: Warm Bodies</itunes:title>
<itunes:episodeType>full</itunes:episodeType>
<itunes:episode>1</itunes:episode>
<itunes:summary>Dakota Prentiss runs security at the secretive Quill Marine compound, run by private defense conglomerate Sierra. Today she's breaking in a new security staffer, Matt Salem. Which means Matt has to pass a crucial test: how he reacts to the secret at the heart of Quill Marine. 
Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel
This week's episode is brought to you by Audible (Audible.com/stealthestars) and Plated (Plated.com/stars. Terms apply).</itunes:summary>
<content:encoded>
<![CDATA[<p>Dakota Prentiss runs security at the secretive Quill Marine compound, run by private defense conglomerate Sierra. Today she's breaking in a new security staffer, Matt Salem. Which means Matt has to pass a crucial test: how he reacts to the secret at the heart of Quill Marine.&nbsp;</p><p>Learn more about Steal the Stars novelization here: http://bit.ly/STSNovel</p><p>This week's episode is brought to you by Audible (<a href="http://Audible.com/stealthestars" target="_blank">Audible.com/stealthestars</a>) and Plated (<a href="http://Plated.com/stars" target="_blank">Plated.com/stars</a>. Terms apply).</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/iKPHluojN_2HVUBDhz25sYOzeMO1xHLITg1JmTyE8nQ</guid>
<pubDate>Wed, 02 Aug 2017 03:00:00 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:27:06</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/f8c867ee-f954-46de-8bda-017305474e40.mp3" type="audio/mpeg" length="21696679"/>
</item>
<item>
<title>Introducing Steal the Stars</title>
<description>
<![CDATA[<p>Steal the Stars is the story of Dakota Prentiss and Matt Salem, two government employees guarding the biggest secret in the world: a crashed UFO. Episode 1 goes live on August 2, 2017.</p>]]>
</description>
<itunes:title>Introducing Steal the Stars</itunes:title>
<itunes:episodeType>trailer</itunes:episodeType>
<itunes:summary>Steal the Stars is the story of Dakota Prentiss and Matt Salem, two government employees guarding the biggest secret in the world: a crashed UFO. Episode 1 goes live on August 2, 2017.</itunes:summary>
<content:encoded>
<![CDATA[<p>Steal the Stars is the story of Dakota Prentiss and Matt Salem, two government employees guarding the biggest secret in the world: a crashed UFO. Episode 1 goes live on August 2, 2017.</p>]]>
</content:encoded>
<guid isPermaLink="false">gid://art19-episode-locator/V0/S6kmOE2cviFS0HD-IUYOPRO0fvjTPYmCsMDe5bjABnA</guid>
<pubDate>Tue, 11 Jul 2017 17:14:45 -0000</pubDate>
<itunes:explicit>yes</itunes:explicit>
<itunes:image href="https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg"/>
<itunes:duration>00:01:22</itunes:duration>
<enclosure url="https://dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/f13b703c-20d9-4ea5-83b6-dbcd2f02351a.mp3" type="audio/mpeg" length="1318661"/>
</item>
</channel>
</rss>

View File

@ -0,0 +1,578 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" xmlns:sm="https://schema.syndicated.media/core/1.0/">
<channel>
<title>The Tip Off</title>
<description><![CDATA[<p>Welcome to The Tip Off- the podcast where we take you behind the scenes of some of the best investigative journalism from recent years. Each episode well be digging into an investigative scoop- hearing from the journalists behind the work as they tell us about the leads, the dead-ends and of course, the tip offs. Therell be car chases, slammed doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling with despotic regimes and much more. So if youre curious about the fun, complicated detective work that goes into doing great investigative journalism- then this is the podcast for you.</p>]]></description>
<link>http://www.acast.com/thetipoff</link>
<lastBuildDate>Mon, 15 Jan 2018 11:59:56 GMT</lastBuildDate>
<pubDate>Thu, 11 Jan 2018 04:00:00 GMT</pubDate>
<ttl>30</ttl>
<language>en</language>
<copyright><![CDATA[]]></copyright>
<docs>https://www.acast.com/thetipoff</docs>
<image>
<url>https://imagecdn.acast.com/image?h=1500&amp;w=1500&amp;source=http%3A%2F%2Fi1.sndcdn.com%2Favatars-000317856075-a2coqz-original.jpg</url>
<title>The Tip Off</title>
<link>http://www.acast.com/thetipoff</link>
</image>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;http%3A%2F%2Fi1.sndcdn.com%2Favatars-000317856075-a2coqz-original.jpg" />
<itunes:subtitle><![CDATA[Welcome to The Tip Off- the podcast where we take…]]></itunes:subtitle>
<itunes:type>episodic</itunes:type>
<itunes:author>The Tip Off</itunes:author>
<itunes:summary>Welcome to The Tip Off- the podcast where we take you behind the scenes of some of the best investigative journalism from recent years. Each episode well be digging into an investigative scoop- hearing from the journalists behind the work as they tell us about the leads, the dead-ends and of course, the tip offs. Therell be car chases, slammed doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling with despotic regimes and much more. So if youre curious about the fun, complicated detective work that goes into doing great investigative journalism- then this is the podcast for you.</itunes:summary>
<atom:link rel="self" type="application/rss+xml" href="https://rss.acast.com/thetipoff" />
<itunes:owner>
<itunes:name><![CDATA[The Tip Off]]></itunes:name>
<itunes:email>tipoffpodcast@gmail.com</itunes:email>
</itunes:owner>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords></itunes:keywords>
<itunes:category text="News &amp; Politics" />
<media:credit role="author">The Tip Off</media:credit>
<media:description type="html"><![CDATA[<p>Welcome to The Tip Off- the podcast where we take you behind the scenes of some of the best investigative journalism from recent years. Each episode well be digging into an investigative scoop- hearing from the journalists behind the work as they tell us about the leads, the dead-ends and of course, the tip offs. Therell be car chases, slammed doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling with despotic regimes and much more. So if youre curious about the fun, complicated detective work that goes into doing great investigative journalism- then this is the podcast for you.</p>]]></media:description>
<item>
<title>Ep.13 Voices in the ether</title>
<itunes:subtitle>When you set out on an investigation you usually start with a hypothesis- an idea that you will test and challenge as you go. But not Paul Myles…
Working for On Our Radar, Paul set out to tell the story of dementia in the UK. Hours spent on trains, ...</itunes:subtitle>
<itunes:summary><![CDATA[When you set out on an investigation you usually start with a hypothesis- an idea that you will test and challenge as you go. But not Paul Myles…
Working for On Our Radar, Paul set out to tell the story of dementia in the UK. Hours spent on trains, workshops around the country and 3D printed phones brought him to Agnes, Melvyn and dozens of others. Together they give a never-before-seen insight into life with the condition.
Read all about it:
https://dementiadiaries.org/
https://www.buzzfeed.com/lukelewis/inspiring-tales-coping-with-dementia?utm_term=.raENZvdoA#.yoaMagBGd
https://www.theguardian.com/society/video/2017/jan/30/dementia-diaries-its-like-trying-to-go-through-a-brick-wall-video
http://www.telegraph.co.uk/science/2016/05/15/dealing-with-dementia-those-living-with-condition-outline-dos-an/
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Lee Rosevere]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[cd7778b1-e81d-4d45-8fe6-cc712190eabe]]></guid>
<pubDate>Thu, 11 Jan 2018 04:00:00 GMT</pubDate>
<itunes:duration>00:33:24</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:season>2</itunes:season>
<itunes:episode>13</itunes:episode>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fassets%2Fcd7778b1-e81d-4d45-8fe6-cc712190eabe%2Fcover-image-jc9kl26c-thetipoff_logo_1_.jpg" />
<description><![CDATA[<p>When you set out on an investigation you usually start with a hypothesis- an idea that you will test and challenge as you go. But not Paul Myles… </p><p><br></p><p>Working for On Our Radar, Paul set out to tell the story of dementia in the UK. Hours spent on trains, workshops around the country and 3D printed phones brought him to Agnes, Melvyn and dozens of others. Together they give a never-before-seen insight into life with the condition.</p><p><br></p><p><strong>Read all about it:</strong></p><p><a href="https://dementiadiaries.org/" target="_blank">https://dementiadiaries.org/</a></p><p><br></p><p><a href="https://www.buzzfeed.com/lukelewis/inspiring-tales-coping-with-dementia?utm_term=.raENZvdoA#.yoaMagBGd" target="_blank">https://www.buzzfeed.com/lukelewis/inspiring-tales-coping-with-dementia?utm_term=.raENZvdoA#.yoaMagBGd</a></p><p><br></p><p><a href="https://www.theguardian.com/society/video/2017/jan/30/dementia-diaries-its-like-trying-to-go-through-a-brick-wall-video" target="_blank">https://www.theguardian.com/society/video/2017/jan/30/dementia-diaries-its-like-trying-to-go-through-a-brick-wall-video</a> </p><p><br></p><p><a href="http://www.telegraph.co.uk/science/2016/05/15/dealing-with-dementia-those-living-with-condition-outline-dos-an/" target="_blank">http://www.telegraph.co.uk/science/2016/05/15/dealing-with-dementia-those-living-with-condition-outline-dos-an/</a></p><p><br></p><p>Hosted and produced: Maeve McClenaghan</p><p><br></p><p>Music: Dice Muse and <a href="http://freemusicarchive.org/music/Lee_Rosevere/Trappist-1/Lee_Rosevere_-_Trappist-1_-_05_Planet_F" target="_blank">Lee Rosevere</a> </p><p><br></p><p><br></p>]]></description>
<link>https://www.acast.com/thetipoff/ep.13voicesintheether</link>
<enclosure url="https://media.acast.com/thetipoff/ep.13voicesintheether/media.mp3" length="56188568" type="audio/mpeg"/>
</item>
<item>
<title>Ep. 12 The sound of news</title>
<itunes:subtitle>Leah Borromeo sees journalism differently to most. Or rather she hears it. Leah and colleagues are working on a project to tell investigative journalism through music.
So Maeve went along to the studio, to hear just exactly what that means.
Read a...</itunes:subtitle>
<itunes:summary><![CDATA[Leah Borromeo sees journalism differently to most. Or rather she hears it. Leah and colleagues are working on a project to tell investigative journalism through music.
So Maeve went along to the studio, to hear just exactly what that means.
Read all about it:
https://www.disobedientfilms.com/if-the-oceans-could-speak
https://www.disobedientfilms.com/climate-symphony
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Climate Symphony]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[7fd9f024-1fd5-48a1-a96b-a3419da9f754]]></guid>
<pubDate>Thu, 28 Dec 2017 03:00:00 GMT</pubDate>
<itunes:duration>00:36:36</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:season>2</itunes:season>
<itunes:episode>12</itunes:episode>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fassets%2F7fd9f024-1fd5-48a1-a96b-a3419da9f754%2Fcover-image-jbo1hf03-thetipoff_logo_1_.jpg" />
<description><![CDATA[<p>Leah Borromeo sees journalism differently to most. Or rather she hears it. Leah and colleagues are working on a project to tell investigative journalism through music.</p><p><br></p><p>So Maeve went along to the studio, to hear just exactly what that means. </p><p><br></p><p>Read all about it:</p><p><a href="https://www.disobedientfilms.com/if-the-oceans-could-speak" target="_blank">https://www.disobedientfilms.com/if-the-oceans-could-speak</a></p><p><br></p><p><a href="https://www.disobedientfilms.com/climate-symphony" target="_blank">https://www.disobedientfilms.com/climate-symphony</a> </p><p><br></p><p>Hosted and produced: Maeve McClenaghan</p><p><br></p><p>Music: Dice Muse and Climate Symphony</p><p><br></p><p><br></p>]]></description>
<link>https://www.acast.com/thetipoff/ep.12thesoundofnews</link>
<enclosure url="https://media.acast.com/thetipoff/ep.12thesoundofnews/media.mp3" length="63865436" type="audio/mpeg"/>
</item>
<item>
<title>Ep.11 Putting a price on health</title>
<itunes:subtitle>Ep. 11 Putting a price on health
Billy Kenber set out to look into one story and ended up finding another. Hidden within open datasets was proof of a practice that was costing the NHS hundreds of millions of pounds a year.
Battling to put the piece...</itunes:subtitle>
<itunes:summary><![CDATA[Billy Kenber set out to look into one story and ended up finding another. Hidden within open datasets was proof of a practice that was costing the NHS hundreds of millions of pounds a year.
Battling to put the pieces together in a quagmire of complex pricing structures and regulations understood by very few people, Billy blew the lid of a multi-million pound industry. And in the end one failed hypothesis led to an investigation that would change the law.
Read all about it:
https://www.thetimes.co.uk/article/extortionate-prices-add-260m-to-nhs-drug-bill-8mwtttwdk
https://www.thetimes.co.uk/article/victory-against-rip-off-drug-firms-after-times-investigation-qm6hlmqts
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Podington Bear]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[2af5efc1-5e6b-446e-9bb3-38704b9392d9]]></guid>
<pubDate>Thu, 14 Dec 2017 04:00:00 GMT</pubDate>
<itunes:duration>00:32:46</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:season>2</itunes:season>
<itunes:episode>11</itunes:episode>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fassets%2F2af5efc1-5e6b-446e-9bb3-38704b9392d9%2Fcover-image-jb5jim69-thetipoff_logo_1_.jpg" />
<description><![CDATA[<p><strong>Ep. 11 Putting a price on health</strong></p><p><br></p><p>Billy Kenber set out to look into one story and ended up finding another. Hidden within open datasets was proof of a practice that was costing the NHS hundreds of millions of pounds a year.</p><p><br></p><p>Battling to put the pieces together in a quagmire of complex pricing structures and regulations understood by very few people, Billy blew the lid of a multi-million pound industry. And in the end one failed hypothesis led to an investigation that would change the law.</p><p><br></p><p>Read all about it:</p><p><br></p><p><a href="https://www.thetimes.co.uk/article/extortionate-prices-add-260m-to-nhs-drug-bill-8mwtttwdk" target="_blank">https://www.thetimes.co.uk/article/extortionate-prices-add-260m-to-nhs-drug-bill-8mwtttwdk</a> </p><p><a href="https://www.thetimes.co.uk/article/victory-against-rip-off-drug-firms-after-times-investigation-qm6hlmqts" target="_blank">https://www.thetimes.co.uk/article/victory-against-rip-off-drug-firms-after-times-investigation-qm6hlmqts</a> </p><p><br></p><p>Hosted and produced: Maeve McClenaghan</p><p><br></p><p>Music: Dice Muse and <a href="http://freemusicarchive.org/music/Podington_Bear/" target="_blank">Podington Bear</a></p><p><br></p>]]></description>
<link>https://www.acast.com/thetipoff/ep.11puttingapriceonhealth</link>
<enclosure url="https://media.acast.com/thetipoff/ep.11puttingapriceonhealth/media.mp3" length="54656753" type="audio/mpeg"/>
</item>
<item>
<title>Ep. 10 Brown paper envelopes</title>
<itunes:subtitle>Jennifer Williams, of the Manchester Evening News, was hearing horrible stories coming from two hospitals in her patch. Rumour had it, there was a damning report out there... but getting hold of it would be easier said than done.
Sources, FOI battles...</itunes:subtitle>
<itunes:summary><![CDATA[Jennifer Williams, of the Manchester Evening News, was hearing horrible stories coming from two hospitals in her patch. Rumour had it, there was a damning report out there... but getting hold of it would be easier said than done.
Sources, FOI battles and an envelope full of surprises- this is the story of how one reporter revealed mothers and babies dying at a worrying rate.
Read all about it:
http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-secret-report-12218989
http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-report-revealed-12220033
Hosted and produced: Maeve McClenaghan
Music: Dice Muse, &nbsp;Komiku, John Spacek and Podington Bear]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[97e601de-45a4-4e6b-bb34-3409081c9202]]></guid>
<pubDate>Thu, 30 Nov 2017 08:45:00 GMT</pubDate>
<itunes:duration>00:34:57</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:season>2</itunes:season>
<itunes:episode>10</itunes:episode>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fassets%2F97e601de-45a4-4e6b-bb34-3409081c9202%2Fcover-image-jam7up44-thetipoff_logo_1_.jpg" />
<description><![CDATA[<p>Jennifer Williams, of the Manchester Evening News, was hearing horrible stories coming from two hospitals in her patch. Rumour had it, there was a damning report out there... but getting hold of it would be easier said than done.</p><p><br></p><p>Sources, FOI battles and an envelope full of surprises- this is the story of how one reporter revealed mothers and babies dying at a worrying rate.</p><p><br></p><p>Read all about it:</p><p><br></p><p><a href="http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-secret-report-12218989" target="_blank">http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-secret-report-12218989</a></p><p><br></p><p><a href="http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-report-revealed-12220033" target="_blank">http://www.manchestereveningnews.co.uk/news/greater-manchester-news/pennine-acute-maternity-report-revealed-12220033</a></p><p><br></p><p>Hosted and produced: Maeve McClenaghan</p><p><br></p><p>Music: Dice Muse,&nbsp;<a href="http://freemusicarchive.org/music/Komiku/Its_time_for_adventure__vol_3/Komiku_-_Its_time_for_adventure_vol_3_-_09_You_yourself_and_the_main_character" target="_blank">Komiku</a>, <a href="http://freemusicarchive.org/" target="_blank">John Spacek</a> and <a href="http://freemusicarchive.org/music/Podington_Bear/" target="_blank">Podington Bear</a></p>]]></description>
<link>https://www.acast.com/thetipoff/ep.10brownpaperenvelopes</link>
<enclosure url="https://media.acast.com/thetipoff/ep.10brownpaperenvelopes/media.mp3" length="59888557" type="audio/mpeg"/>
</item>
<item>
<title>Ep.9 When the roof came down</title>
<itunes:subtitle>Ep.9 When the roof came down In late July I read a Facebook post that would send me on a three month journey- from town council steps, to bland hotels to dirty council flats- I followed the stories of women fleeing domestic violence only to be let down...</itunes:subtitle>
<itunes:summary><![CDATA[Ep.9 When the roof came down In late July I read a Facebook post that would send me on a three month journey- from town council steps, to bland hotels to dirty council flats- I followed the stories of women fleeing domestic violence only to be let down by the system designed to support them. Meanwhile, a team of journalists all across the country, dug into FOI data, pulled in local council funding bids and surveyed experts on the ground. Together we uncovered a country-wide crisis, with huge cuts to refuge funding and hundreds of vulnerable women turned away. WARNING: This episodes contains descriptions of domestic violence and strong language. If you need to talk to someone about domestic violence contact Women's Aid/Refuge free helpline on 0808 2000 247. If you are in immediate danger, call 999. Read all about it: https://www.thebureauinvestigates.com/stories/2017-10-16/a-system-at-breaking-point https://www.thebureauinvestigates.com/stories/2017-10-16/new-entry https://www.thebureauinvestigates.com/blog/2017-10-19/refuges-at-breaking-point-stories-from-around-the-country Hosted and produced: Maeve McClenaghan Testimony voiced by: Emer O Connor and Stephanie Soh Music: Dice Muse and Komiku http://freemusicarchive.org/music/Komiku/Its_time_for_adventure__vol_3/Komiku_-_Its_time_for_adventure_vol_3_-_09_You_yourself_and_the_main_character]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/348656618]]></guid>
<pubDate>Thu, 26 Oct 2017 09:16:00 GMT</pubDate>
<itunes:duration>00:46:16</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>yes</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2Fd320b821-f2ba-4029-b95b-0cb840bfa8d8%2F44cc9c2e-879f-49c6-8b34-4d38d09e85d2%2Fartworks-000248917923-77ja4z-original.jpg" />
<description><![CDATA[<p>Ep.9 When the roof came down In late July I read a Facebook post that would send me on a three month journey- from town council steps, to bland hotels to dirty council flats- I followed the stories of women fleeing domestic violence only to be let down by the system designed to support them. Meanwhile, a team of journalists all across the country, dug into FOI data, pulled in local council funding bids and surveyed experts on the ground. Together we uncovered a country-wide crisis, with huge cuts to refuge funding and hundreds of vulnerable women turned away. WARNING: This episodes contains descriptions of domestic violence and strong language. If you need to talk to someone about domestic violence contact Women's Aid/Refuge free helpline on 0808 2000 247. If you are in immediate danger, call 999. Read all about it: https://www.thebureauinvestigates.com/stories/2017-10-16/a-system-at-breaking-point https://www.thebureauinvestigates.com/stories/2017-10-16/new-entry https://www.thebureauinvestigates.com/blog/2017-10-19/refuges-at-breaking-point-stories-from-around-the-country Hosted and produced: Maeve McClenaghan Testimony voiced by: Emer O Connor and Stephanie Soh Music: Dice Muse and Komiku http://freemusicarchive.org/music/Komiku/Its_time_for_adventure__vol_3/Komiku_-_Its_time_for_adventure_vol_3_-_09_You_yourself_and_the_main_character</p>]]></description>
<link>https://www.acast.com/thetipoff/ep9-when-the-roof-came-down</link>
<enclosure url="https://media.acast.com/thetipoff/ep9-when-the-roof-came-down/media.mp3" length="87063215" type="audio/mpeg"/>
</item>
<item>
<title>Ep.8 The stories untold</title>
<itunes:subtitle>Ep.8 The stories untold
Its one thing breaking a story- but how do you keep reporting a story that unfurls over years and not days?
Rebecca Omonira-Oyekanmi explains how she travelled to the vast refugee camps of Ethiopia before Rossalyn Warren takes...</itunes:subtitle>
<itunes:summary><![CDATA[Ep.8 The stories untold
Its one thing breaking a story- but how do you keep reporting a story that unfurls over years and not days?
Rebecca Omonira-Oyekanmi explains how she travelled to the vast refugee camps of Ethiopia before Rossalyn Warren takes up the baton, reporting from the perilous sea-crossings on the Mediterranean.
Working freelance, both women struggle to do the stories they care about while making a living.
WARNING: This episodes contains descriptions of domestic violence.
Read all about it:
http://www.newstatesman.com/world/africa/2017/03/i-want-try-live-or-die-how-refugees-decide-whether-make-dangerous-trip-europe
http://www.elleuk.com/life-and-culture/culture/longform/a36785/moroccan-teen-girl-fleeing-slavery/
Hosted and produced: Maeve McClenaghan
Music: Dice Muse, Clare Marks and Komiku
Audio of sea rescues from Medicins Sans Frontier
https://soundcloud.com/claremarks]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/335067535]]></guid>
<pubDate>Thu, 27 Jul 2017 12:53:18 GMT</pubDate>
<itunes:duration>00:41:20</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2Fd3c055ed-eb98-42e1-8ef1-e5e201d58a24%2Ffa8e7880-4001-4f1a-b480-e6e04f709f96%2Fartworks-000235413887-wac8v7-original.jpg" />
<description><![CDATA[Ep.8 The stories untold
Its one thing breaking a story- but how do you keep reporting a story that unfurls over years and not days?
Rebecca Omonira-Oyekanmi explains how she travelled to the vast refugee camps of Ethiopia before Rossalyn Warren takes up the baton, reporting from the perilous sea-crossings on the Mediterranean.
Working freelance, both women struggle to do the stories they care about while making a living.
WARNING: This episodes contains descriptions of domestic violence.
Read all about it:
http://www.newstatesman.com/world/africa/2017/03/i-want-try-live-or-die-how-refugees-decide-whether-make-dangerous-trip-europe
http://www.elleuk.com/life-and-culture/culture/longform/a36785/moroccan-teen-girl-fleeing-slavery/
Hosted and produced: Maeve McClenaghan
Music: Dice Muse, Clare Marks and Komiku
Audio of sea rescues from Medicins Sans Frontier
https://soundcloud.com/claremarks]]></description>
<link>https://www.acast.com/thetipoff/ep.8-the-stories-untold</link>
<enclosure url="https://media.acast.com/thetipoff/ep.8-the-stories-untold/media.mp3" length="75233921" type="audio/mpeg"/>
</item>
<item>
<title>Ep.7 Codename Prometheus</title>
<itunes:subtitle>Ep. 7 Codename: Prometheus
Everyones heard of the Panama Papers- the stories from the biggest data lead in history rocked the world.
But how did it happen? Where did the data appear from? How do you sift through 11.5m files? And how on earth do you ...</itunes:subtitle>
<itunes:summary><![CDATA[Ep. 7 Codename: Prometheus
Everyones heard of the Panama Papers- the stories from the biggest data lead in history rocked the world.
But how did it happen? Where did the data appear from? How do you sift through 11.5m files? And how on earth do you herd hundreds of journalists to the same finish line?
Bastian Obermayer (Süddeutsche Zeitung), Holly Watt (Guardian) and Will Fitzgibbon (ICIJ) talk us through the trials and tribulations of the worlds biggest cross-border collaboration.
Read all about it:
https://www.theguardian.com/news/2016/apr/07/david-cameron-admits-he-profited-fathers-offshore-fund-panama-papers
https://panamapapers.icij.org/about.html
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Josh Spacek]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/333989206]]></guid>
<pubDate>Thu, 20 Jul 2017 07:07:45 GMT</pubDate>
<itunes:duration>00:39:41</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2F66be48d0-9970-4254-a8ea-82a1066e2337%2Fe7762b1f-aada-433b-9edd-1a5ac348ab98%2Fartworks-000234362942-lmh754-original.jpg" />
<description><![CDATA[Ep. 7 Codename: Prometheus
Everyones heard of the Panama Papers- the stories from the biggest data lead in history rocked the world.
But how did it happen? Where did the data appear from? How do you sift through 11.5m files? And how on earth do you herd hundreds of journalists to the same finish line?
Bastian Obermayer (Süddeutsche Zeitung), Holly Watt (Guardian) and Will Fitzgibbon (ICIJ) talk us through the trials and tribulations of the worlds biggest cross-border collaboration.
Read all about it:
https://www.theguardian.com/news/2016/apr/07/david-cameron-admits-he-profited-fathers-offshore-fund-panama-papers
https://panamapapers.icij.org/about.html
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Josh Spacek]]></description>
<link>https://www.acast.com/thetipoff/ep.7-codename-prometheus</link>
<enclosure url="https://media.acast.com/thetipoff/ep.7-codename-prometheus/media.mp3" length="71273758" type="audio/mpeg"/>
</item>
<item>
<title>Ep.6 Caught offside?</title>
<itunes:subtitle>Ep. 6 Caught offside?
A two year investigation took reporters undercover as they met with top football managers and agents.
Claire Newell explains how the Daily Telegraphs investigation team exposed the murky world of British football, ending in the ...</itunes:subtitle>
<itunes:summary><![CDATA[Ep. 6 Caught offside?
A two year investigation took reporters undercover as they met with top football managers and agents.
Claire Newell explains how the Daily Telegraphs investigation team exposed the murky world of British football, ending in the England manager stepping down.
Read all about it:
http://www.telegraph.co.uk/news/2016/09/26/exclusive-investigation-england-manager-sam-allardyce-for-sale/
http://www.telegraph.co.uk/football-for-sale/
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Clare Marks
https://soundcloud.com/claremarks]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/332988171]]></guid>
<pubDate>Thu, 13 Jul 2017 07:44:20 GMT</pubDate>
<itunes:duration>00:44:11</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2Fdb22e275-5348-44f8-a975-e2ae773910cf%2F3419a5d8-7ad2-41fb-bc03-861b4b7dd0b4%2Fartworks-000233400257-9pzv50-original.jpg" />
<description><![CDATA[Ep. 6 Caught offside?
A two year investigation took reporters undercover as they met with top football managers and agents.
Claire Newell explains how the Daily Telegraphs investigation team exposed the murky world of British football, ending in the England manager stepping down.
Read all about it:
http://www.telegraph.co.uk/news/2016/09/26/exclusive-investigation-england-manager-sam-allardyce-for-sale/
http://www.telegraph.co.uk/football-for-sale/
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Clare Marks
https://soundcloud.com/claremarks]]></description>
<link>https://www.acast.com/thetipoff/ep.6-caught-offside</link>
<enclosure url="https://media.acast.com/thetipoff/ep.6-caught-offside/media.mp3" length="82073824" type="audio/mpeg"/>
</item>
<item>
<title>Ep.5 It started with a body</title>
<itunes:subtitle>Ep. 5 It started with a body
It started with a body and ended up in a months long investigation exploring the scale of homelessness in a London borough.
We follow Emma Youle as one simple story grows and morphs into an award-winning campaign.
Read a...</itunes:subtitle>
<itunes:summary><![CDATA[Ep. 5 It started with a body
It started with a body and ended up in a months long investigation exploring the scale of homelessness in a London borough.
We follow Emma Youle as one simple story grows and morphs into an award-winning campaign.
Read all about it: http://www.hackneygazette.co.uk/news/hackney-council-pays-35million-a-year-to-keep-the-homeless-homeless-1-4851576
http://www.hackneygazette.co.uk/news/revealed-shocking-modern-day-slum-conditions-at-hackney-hostel-for-homeless-people-1-4589067
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Podington Bear]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/331807588]]></guid>
<pubDate>Thu, 06 Jul 2017 06:43:02 GMT</pubDate>
<itunes:duration>00:38:17</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2F032b9688-585f-417d-903b-b651d8e3b20b%2F7e397853-7dad-4f38-b69f-8fa88b4567e8%2Fartworks-000232222022-dx0w5x-original.jpg" />
<description><![CDATA[Ep. 5 It started with a body
It started with a body and ended up in a months long investigation exploring the scale of homelessness in a London borough.
We follow Emma Youle as one simple story grows and morphs into an award-winning campaign.
Read all about it: http://www.hackneygazette.co.uk/news/hackney-council-pays-35million-a-year-to-keep-the-homeless-homeless-1-4851576
http://www.hackneygazette.co.uk/news/revealed-shocking-modern-day-slum-conditions-at-hackney-hostel-for-homeless-people-1-4589067
Hosted and produced: Maeve McClenaghan
Music: Dice Muse and Podington Bear]]></description>
<link>https://www.acast.com/thetipoff/ep.5-it-started-with-a-body</link>
<enclosure url="https://media.acast.com/thetipoff/ep.5-it-started-with-a-body/media.mp3" length="67903969" type="audio/mpeg"/>
</item>
<item>
<title>Ep.4 Knock Knock</title>
<itunes:subtitle>Ep.4 Knock knock
Jane Bradley is stood on doorstep in West London. She is about to knock and tell a mother that she suspects her son is one of the worlds most wanted terrorists.
We follow Janes progress as she tracks down not one but two of the Bea...</itunes:subtitle>
<itunes:summary><![CDATA[Ep.4 Knock knock
Jane Bradley is stood on doorstep in West London. She is about to knock and tell a mother that she suspects her son is one of the worlds most wanted terrorists.
We follow Janes progress as she tracks down not one but two of the Beatles terror cell.
Read all about it: https://www.buzzfeed.com/janebradley/unmasked-the-second-member-of-isiss-beatles-execution-cell?utm_term=.tbXjXVDRw#.vvG4w1PG7
https://www.buzzfeed.com/janebradley/my-son-the-isis-executioner?utm_term=.uxO3lDBw9#.uqWJrqAvR
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/330669491]]></guid>
<pubDate>Thu, 29 Jun 2017 07:53:28 GMT</pubDate>
<itunes:duration>00:35:05</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2F21aabb14-b47f-4bef-8cc0-b36eb65bf00a%2F4b5be8c8-9f36-440b-8422-19e220b39fb3%2Fartworks-000231099552-8kn14m-original.jpg" />
<description><![CDATA[Ep.4 Knock knock
Jane Bradley is stood on doorstep in West London. She is about to knock and tell a mother that she suspects her son is one of the worlds most wanted terrorists.
We follow Janes progress as she tracks down not one but two of the Beatles terror cell.
Read all about it: https://www.buzzfeed.com/janebradley/unmasked-the-second-member-of-isiss-beatles-execution-cell?utm_term=.tbXjXVDRw#.vvG4w1PG7
https://www.buzzfeed.com/janebradley/my-son-the-isis-executioner?utm_term=.uxO3lDBw9#.uqWJrqAvR
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></description>
<link>https://www.acast.com/thetipoff/ep.4-knock-knock</link>
<enclosure url="https://media.acast.com/thetipoff/ep.4-knock-knock/media.mp3" length="60211376" type="audio/mpeg"/>
</item>
<item>
<title>Ep.3 Back to the source</title>
<itunes:subtitle>Ep.3 Back to the source
The Bureau of Investigative Journalisms Abigail Fielding-Smith finds a vital source, who lets her into the world of the Pentagons top-secret propaganda machine.
With: Abigail Fielding-Smith
Read all about it: http://labs....</itunes:subtitle>
<itunes:summary><![CDATA[Ep.3 Back to the source
The Bureau of Investigative Journalisms Abigail Fielding-Smith finds a vital source, who lets her into the world of the Pentagons top-secret propaganda machine.
With: Abigail Fielding-Smith
Read all about it: http://labs.thebureauinvestigates.com/fake-news-and-false-flags/
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/329457734]]></guid>
<pubDate>Thu, 22 Jun 2017 07:18:17 GMT</pubDate>
<itunes:duration>00:40:45</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2Ff12efe36-3be7-4ab3-88c5-d74029f554cb%2F9fb70bc0-ec6a-40f6-9233-3f768356e6ce%2Fartworks-000229985808-w1scxb-original.jpg" />
<description><![CDATA[Ep.3 Back to the source
The Bureau of Investigative Journalisms Abigail Fielding-Smith finds a vital source, who lets her into the world of the Pentagons top-secret propaganda machine.
With: Abigail Fielding-Smith
Read all about it: http://labs.thebureauinvestigates.com/fake-news-and-false-flags/
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></description>
<link>https://www.acast.com/thetipoff/ep.3-back-to-the-source</link>
<enclosure url="https://media.acast.com/thetipoff/ep.3-back-to-the-source/media.mp3" length="73843165" type="audio/mpeg"/>
</item>
<item>
<title>Ep.2 The stuff of horror movies</title>
<itunes:subtitle>Ep. 2 The stuff of horror movies
Piece by piece the Washington Posts Louisa Loveluck puts together the picture of a horrifying Syrian torture facility, in a setting you wouldnt expect.
WARNING: This episode includes descriptions of violence and to...</itunes:subtitle>
<itunes:summary><![CDATA[Ep. 2 The stuff of horror movies
Piece by piece the Washington Posts Louisa Loveluck puts together the picture of a horrifying Syrian torture facility, in a setting you wouldnt expect.
WARNING: This episode includes descriptions of violence and torture and may not be suitable for all listeners.
With: Louisa Loveluck
Read all about it: https://www.washingtonpost.com/world/middle_east/the-hospitals-were-slaughterhouses-a-journey-intosyrias-secret-torture-wards/2017/04/02/90ccaa6e-0d61-11e7-b2bb-417e331877d9_story.html?utm_term=.78c0ffec5d51
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse and Podington Bear]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/327984833]]></guid>
<pubDate>Wed, 14 Jun 2017 07:39:16 GMT</pubDate>
<itunes:duration>00:31:58</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2F421a1d64-4f8d-4071-91d8-3181ef188f9b%2Ff21198e8-9018-4e3b-91eb-7d75b029b300%2Fartworks-000228291000-fdcndf-original.jpg" />
<description><![CDATA[Ep. 2 The stuff of horror movies
Piece by piece the Washington Posts Louisa Loveluck puts together the picture of a horrifying Syrian torture facility, in a setting you wouldnt expect.
WARNING: This episode includes descriptions of violence and torture and may not be suitable for all listeners.
With: Louisa Loveluck
Read all about it: https://www.washingtonpost.com/world/middle_east/the-hospitals-were-slaughterhouses-a-journey-intosyrias-secret-torture-wards/2017/04/02/90ccaa6e-0d61-11e7-b2bb-417e331877d9_story.html?utm_term=.78c0ffec5d51
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse and Podington Bear]]></description>
<link>https://www.acast.com/thetipoff/ep.2-the-stuff-of-horror-movies</link>
<enclosure url="https://media.acast.com/thetipoff/ep.2-the-stuff-of-horror-movies/media.mp3" length="52760373" type="audio/mpeg"/>
</item>
<item>
<title>Ep.1 Follow the money</title>
<itunes:subtitle>Ep.1 Follow the money
Wigs, car chases and rucksacks stuffed with cash. Buzzfeeds Heidi Blake explains how she exposed the questionable financial practices of one of the Conservative Partys largest donors.
With: Heidi Blake
Read all about it: ht...</itunes:subtitle>
<itunes:summary><![CDATA[Ep.1 Follow the money
Wigs, car chases and rucksacks stuffed with cash. Buzzfeeds Heidi Blake explains how she exposed the questionable financial practices of one of the Conservative Partys largest donors.
With: Heidi Blake
Read all about it: https://www.buzzfeed.com/heidiblake/this-tory-donor-was-secretly-filmed-dropping-cash-stuffed-ru?utm_term=.oxjmBQGKN#.qneDMy19Q
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/327983748]]></guid>
<pubDate>Wed, 14 Jun 2017 07:21:57 GMT</pubDate>
<itunes:duration>00:37:50</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2Ff37fae08-d65c-45cf-9957-55a045a87cd7%2F97e8631f-4e55-41a3-87c1-29c6114e9b83%2Fartworks-000228290187-pd4yfy-original.jpg" />
<description><![CDATA[Ep.1 Follow the money
Wigs, car chases and rucksacks stuffed with cash. Buzzfeeds Heidi Blake explains how she exposed the questionable financial practices of one of the Conservative Partys largest donors.
With: Heidi Blake
Read all about it: https://www.buzzfeed.com/heidiblake/this-tory-donor-was-secretly-filmed-dropping-cash-stuffed-ru?utm_term=.oxjmBQGKN#.qneDMy19Q
Hosted and produced: Maeve McClenaghan
Production advice: Lorna Stewart
Music: Dice Muse]]></description>
<link>https://www.acast.com/thetipoff/ep.1-follow-the-money</link>
<enclosure url="https://media.acast.com/thetipoff/ep.1-follow-the-money/media.mp3" length="66839221" type="audio/mpeg"/>
</item>
<item>
<title>Coming soon... The Tip Off</title>
<itunes:subtitle>Welcome to The Tip Off, a new weekly podcast that delves into the stories behind some of the biggest headlines in British journalism.</itunes:subtitle>
<itunes:summary><![CDATA[Welcome to The Tip Off, a new weekly podcast that delves into the stories behind some of the biggest headlines in British journalism.]]></itunes:summary>
<guid isPermaLink="false"><![CDATA[tag:soundcloud,2010:tracks/327539708]]></guid>
<pubDate>Sun, 11 Jun 2017 12:24:16 GMT</pubDate>
<itunes:duration>00:11:39</itunes:duration>
<itunes:keywords></itunes:keywords>
<itunes:explicit>no</itunes:explicit>
<itunes:episodeType>full</itunes:episodeType>
<itunes:image href="https://imagecdn.acast.com/image?h&#x3D;1500&amp;w&#x3D;1500&amp;source&#x3D;https%3A%2F%2Fmediacdn.acast.com%2Fsource%2F945dc34f-f4e6-4921-830a-df050e540722%2F9e2116e5-b87e-43a7-ae0c-41cbb324b4d8%2F7d456cf4-3671-4d98-ae5c-1c2e92b13527%2Fartworks-000227873559-gxybj6-original.jpg" />
<description><![CDATA[Welcome to The Tip Off, a new weekly podcast that delves into the stories behind some of the biggest headlines in British journalism.]]></description>
<link>https://www.acast.com/thetipoff/coming-soon...-the-tip-off</link>
<enclosure url="https://media.acast.com/thetipoff/coming-soon...-the-tip-off/media.mp3" length="3978132" type="audio/mpeg"/>
</item>
</channel>
</rss>

File diff suppressed because it is too large Load Diff

View File

@ -1,139 +0,0 @@
<?xml version="1.0"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Request For Explanation</title>
<link>https://request-for-explanation.github.io/podcast/</link>
<description>A weekly discussion of Rust RFCs</description>
<image>
<url>https://request-for-explanation.github.io/podcast/podcast.png</url>
<title>Request For Explanation</title>
<link>https://request-for-explanation.github.io/podcast/</link>
</image>
<atom:link href="http://request-for-explanation.github.io/podcast/rss.xml" rel="self" type="application/rss+xml" />
<googleplay:author>The Request For Explanation Podcast</googleplay:author>
<itunes:author>The Request For Explanation Podcast</itunes:author>
<googleplay:email>manishearth@gmail.com</googleplay:email>
<itunes:owner>
<itunes:name>Manish Goregaokar</itunes:name>
<itunes:email>manishearth@gmail.com</itunes:email>
</itunes:owner>
<googleplay:image href="https://request-for-explanation.github.io/podcast/podcast.png" />
<itunes:image href="https://request-for-explanation.github.io/podcast/podcast.png" />
<language>en-us</language>
<googleplay:category text="Technology"/>
<itunes:category text="Technology" />
<itunes:explicit>no</itunes:explicit>
<item>
<title>Episode #0 - What the Hell</title>
<link>https://request-for-explanation.github.io/podcast/ep0-what-the-hell/</link>
<pubDate>Mon, 19 Jun 2017 19:45:01 EST</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep0-what-the-hell/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep0-what-the-hell/episode.mp3" length="7057920" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2005">RFC 2005</a> "Match Ergonomics Using Default Binding Modes"]]></description>
<itunes:order>1</itunes:order>
<itunes:duration>20:29</itunes:duration>
</item>
<item>
<title>Episode #1 - Constermash</title>
<link>https://request-for-explanation.github.io/podcast/ep1-constermash/</link>
<pubDate>Thu, 29 Jun 2017 17:30:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep1-constermash/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep1-constermash/episode.mp3" length="28588800" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2000">RFC 2000</a> "Const Generics"]]></description>
<itunes:order>2</itunes:order>
<itunes:duration>16:09</itunes:duration>
</item>
<item>
<title>Episode #2 - Stealing Chickens on the Internet</title>
<link>https://request-for-explanation.github.io/podcast/ep2-stealing-chickens-on-the-internet/</link>
<pubDate>Thu, 6 July 2017 15:30:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep2-stealing-chickens-on-the-internet</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep2-stealing-chickens-on-the-internet/episode.mp3" length="19608187" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2052">RFC 2052</a> "Evolving Rust through Epochs"]]></description>
<itunes:order>3</itunes:order>
<itunes:duration>43:25</itunes:duration>
</item>
<item>
<title>Episode #3 - Aaron's Favorite Topic</title>
<link>https://request-for-explanation.github.io/podcast/ep3-aarons-favorite-topic/</link>
<pubDate>Mon, 10 July 2017 16:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep3-aarons-favorite-topic</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep3-aarons-favorite-topic/episode.mp3" length="19070229" type="audio/mpeg" />
<description><![CDATA[This week we talk about the RFC process in general -- what it is, how it works, and how it came to be.]]></description>
<itunes:order>4</itunes:order>
<itunes:duration>54:01</itunes:duration>
</item>
<item>
<title>Episode #4 - Literally Haskell</title>
<link>https://request-for-explanation.github.io/podcast/ep4-literally-haskell/</link>
<pubDate>Mon, 17 July 2017 17:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep4-literally-haskell</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep4-literally-haskell/episode.mp3" length="12973478" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/1598">RFC 1598</a> "Generic Associated Types"]]></description>
<itunes:order>5</itunes:order>
<itunes:duration>36:41</itunes:duration>
</item>
<item>
<title>Episode #5 - Are you my main?</title>
<link>https://request-for-explanation.github.io/podcast/ep5-are-you-my-main/</link>
<pubDate>Mon, 24 July 2017 16:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep5-are-you-my-main</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep5-are-you-my-main/episode.mp3" length="8232966" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/1937">RFC 1937</a> "? in main", as well as discuss some news on other RFCs.]]></description>
<itunes:order>6</itunes:order>
<itunes:duration>22:33</itunes:duration>
</item>
<item>
<title>Episode #6 - Everything and the kitchen async</title>
<link>https://request-for-explanation.github.io/podcast/ep6-everything-and-the-kitchen-async/</link>
<pubDate>Mon, 31 July 2017 16:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep6-everything-and-the-kitchen-async/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep6-everything-and-the-kitchen-async/episode.mp3" length="9530774" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2033">eRFC 2033</a> "Experimentally add coroutines to Rust"]]></description>
<itunes:order>7</itunes:order>
<itunes:duration>25:52</itunes:duration>
</item>
<item>
<title>Episode #7 - Unwrapping a great RFC</title>
<link>https://request-for-explanation.github.io/podcast/ep7-unwrapping-a-great-rfc/</link>
<pubDate>Tue, 8 Aug 2017 16:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep7-unwrapping-a-great-rfc/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep7-unwrapping-a-great-rfc/episode.mp3" length="8715317" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2091">RFC 2091</a> "Implicit caller location"]]></description>
<itunes:order>8</itunes:order>
<itunes:duration>24:11</itunes:duration>
</item>
<item>
<title>Episode #8 - An Existential Crisis</title>
<link>https://request-for-explanation.github.io/podcast/ep8-an-existential-crisis/</link>
<pubDate>Tue, 15 Aug 2017 17:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep8-an-existential-crisis/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep8-an-existential-crisis/episode.mp3" length="13713219" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2071">RFC 2071</a> "Add impl Trait type alias and variable declarations"]]></description>
<itunes:order>9</itunes:order>
<itunes:duration>38:33</itunes:duration>
</item>
<item>
<title>Episode #9 - A Once in a Lifetime RFC</title>
<link>https://request-for-explanation.github.io/podcast/ep9-a-once-in-a-lifetime-rfc/</link>
<pubDate>Mon, 28 Aug 2017 15:00:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep9-a-once-in-a-lifetime-rfc/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep9-a-once-in-a-lifetime-rfc/episode.mp3" length="15077388" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2094">RFC 2094</a> "Non-lexical lifetimes"]]></description>
<itunes:order>10</itunes:order>
<itunes:duration>42:13</itunes:duration>
</item>
<item>
<title>Episode #10 - Two Paths Diverged in a Yellow Wood</title>
<link>https://request-for-explanation.github.io/podcast/ep10-two-paths-diverged-in-a-yellow-wood/</link>
<pubDate>Thu, 30 Aug 2017 1:30:00 PDT</pubDate>
<guid isPermaLink="false">https://request-for-explanation.github.io/podcast/ep10-two-paths-diverged-in-a-yellow-wood/</guid>
<enclosure url="http://request-for-explanation.github.io/podcast/ep10-two-paths-diverged-in-a-yellow-wood/episode.mp3" length="19994929" type="audio/mpeg" />
<description><![CDATA[This week we look at <a href="https://github.com/rust-lang/rfcs/pull/2126">RFC 2126</a> "Clarify and streamline paths and visibility" (aka "The modules RFC")]]></description>
<itunes:order>11</itunes:order>
<itunes:duration>56:40</itunes:duration>
</item>
</channel>
</rss>

View File

@ -1,457 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" media="screen" href="/~files/feed-premium.xsl"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:admin="http://webns.net/mvcb/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:feedpress="https://feed.press/xmlns" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0" xmlns:media="http://www.rssboard.org/media-rss" version="2.0">
<channel>
<feedpress:locale>en</feedpress:locale>
<atom:link rel="via" href="http://feeds.propublica.org/propublica/podcast"/>
<atom:link rel="hub" href="http://feedpress.superfeedr.com/"/>
<itunes:category text="News &amp; Politics"/>
<media:category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">News &amp; Politics</media:category>
<itunes:category text="Society &amp; Culture">
<itunes:category text="History"/>
</itunes:category>
<media:category scheme="http://www.itunes.com/dtds/podcast-1.0.dtd">Society &amp; Culture/History</media:category>
<media:rating>nonadult</media:rating>
<media:description type="plain">The ProPublica Podcast</media:description>
<media:credit role="author">ProPublica</media:credit>
<media:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</media:keywords>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<googleplay:description>The podcast that takes you behind the scenes with journalists to hear how they nailed their biggest stories.</googleplay:description>
<googleplay:author>ProPublica</googleplay:author>
<googleplay:email>celeste.lecompte@propublica.org</googleplay:email>
<googleplay:explicit>no</googleplay:explicit>
<title>The Breakthrough</title>
<link>http://www.propublica.org/podcast</link>
<description>Latest Articles and Investigations from ProPublica, an independent, non-profit newsroom that produces investigative journalism in the public interest.</description>
<language>en</language>
<dc:creator>ProPublica</dc:creator>
<copyright>Copyright 2017 Pro Publica Inc.</copyright>
<pubDate>Fri, 22 Sep 2017 13:22:48 +0000</pubDate>
<dc:date>2017-09-22T13:22:48+00:00</dc:date>
<dc:language>en-us</dc:language>
<dc:rights>Copyright 2017 Pro Publica Inc.</dc:rights>
<atom:link href="http://feeds.propublica.org/propublica/podcast" rel="self" type="application/rss+xml"/>
<itunes:subtitle>The ProPublica Podcast</itunes:subtitle>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>The podcast that takes you behind the scenes with journalists to hear how they nailed their biggest stories.</itunes:summary>
<itunes:explicit>no</itunes:explicit>
<itunes:owner>
<itunes:email>celeste.lecompte@propublica.org</itunes:email>
<itunes:name>ProPublica</itunes:name>
</itunes:owner>
<itunes:image href="http://www.propublica.org/images/podcast_logo_2.png"/>
<item>
<title>The Breakthrough: Hopelessness and Exploitation Inside Homes for Mentally Ill</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>A reporter finds that homes meant to replace New Yorks troubled psychiatric hospitals might be just as bad.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726758/20170908-cliff-levy.mp3" length="33396551" type="audio/mpeg"/>
<itunes:duration>00:27:50</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-hopelessness-exploitation-homes-for-mentally-ill#134472</link>
<pubDate>Fri, 08 Sep 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-hopelessness-exploitation-homes-for-mentally-ill#134472</guid>
<description><![CDATA[
<p>A reporter finds that homes meant to replace New Yorks troubled psychiatric hospitals might be just as bad.</p>
]]></description>
<dc:subject/>
<dc:date>2017-09-08T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
A reporter finds that homes meant to replace New Yorks troubled psychiatric hospitals might be just as bad.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Behind the Scenes of Hillary Clintons Failed Bid for President</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>Jonathan Allen and Amie Parnes didnt know their book would be called Shattered, or that their extraordinary access would let them chronicle the mounting signs of a doomed campaign.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726759/16_JohnAllen-CRAFT.mp3" length="17964071" type="audio/mpeg"/>
<itunes:duration>00:18:45</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-hillary-clinton-failed-presidential-bid#133721</link>
<pubDate>Fri, 25 Aug 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-hillary-clinton-failed-presidential-bid#133721</guid>
<description><![CDATA[
<p>Jonathan Allen and Amie Parnes didnt know their book would be called Shattered, or that their extraordinary access would let them chronicle the mounting signs of a doomed campaign.</p>
]]></description>
<dc:subject/>
<dc:date>2017-08-25T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
Jonathan Allen and Amie Parnes didnt know their book would be called Shattered, or that their extraordinary access would let them chronicle the mounting signs of a doomed campaign.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: How a Small News Outlet Brought Down the State Hero</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>VTDiggers Anne Galloway was suspicious the moment she heard about a too-good-to-be-true development. She didnt know how right she was.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726760/15_VTDigger-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/the-breakthrough-how-a-small-news-outlet-brought-down-the-state-hero#133361</link>
<pubDate>Fri, 11 Aug 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-how-a-small-news-outlet-brought-down-the-state-hero#133361</guid>
<description><![CDATA[
<p>VTDiggers Anne Galloway was suspicious the moment she heard about a too-good-to-be-true development. She didnt know how right she was.</p>
]]></description>
<dc:subject/>
<dc:date>2017-08-11T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
VTDiggers Anne Galloway was suspicious the moment she heard about a too-good-to-be-true development. She didnt know how right she was.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Reporting on Life and Death in the Delivery Room</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>ProPublica reporter Nina Martin and her team used social media and old-fashioned shoe leather to show how the U.S. has the worst maternal death rate in the developed world.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726761/14_MaternalMortality-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/the-breakthrough-reporting-on-life-and-death-in-the-delivery-room#133354</link>
<pubDate>Fri, 28 Jul 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-reporting-on-life-and-death-in-the-delivery-room#133354</guid>
<description><![CDATA[
<p>ProPublica reporter Nina Martin and her team used social media and old-fashioned shoe leather to show how the U.S. has the worst maternal death rate in the developed world.</p>
]]></description>
<dc:subject>Health CareLost Mothers</dc:subject>
<dc:date>2017-07-28T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
ProPublica reporter Nina Martin and her team used social media and old-fashioned shoe leather to show how the U.S. has the worst maternal death rate in the developed world.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: How an ICIJ Reporter Dug Up the World Banks Best Kept Secret</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>Sasha Chavkin chased it down across three continents, and into places he was warned weren't safe.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726762/13_WorldBank-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/the-breakthrough-how-an-icij-reporter-dug-up-world-banks-best-kept-secret#133343</link>
<pubDate>Fri, 14 Jul 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-how-an-icij-reporter-dug-up-world-banks-best-kept-secret#133343</guid>
<description><![CDATA[
<p>Sasha Chavkin chased it down across three continents, and into places he was warned weren&#039;t safe.</p>
]]></description>
<dc:subject/>
<dc:date>2017-07-14T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
Sasha Chavkin chased it down across three continents, and into places he was warned weren't safe.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: A Reporter Crosses Borders to Uncover Labor Abuse</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>ProPublicas Michael Grabell travels from the heart of Ohio to the mountains of Guatemala to track down immigrant workers harmed in American poultry plants.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726763/12_CaseFarms_Grabell-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/the-breakthrough-case-farms-labor-abuse-guatemala-michael-grabell#133335</link>
<pubDate>Fri, 30 Jun 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-case-farms-labor-abuse-guatemala-michael-grabell#133335</guid>
<description><![CDATA[
<p>ProPublicas Michael Grabell travels from the heart of Ohio to the mountains of Guatemala to track down immigrant workers harmed in American poultry plants.</p>
]]></description>
<dc:subject>Labor</dc:subject>
<dc:date>2017-06-30T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
ProPublicas Michael Grabell travels from the heart of Ohio to the mountains of Guatemala to track down immigrant workers harmed in American poultry plants.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Uncovering NYC Cops Making Millions in Suspicious Deals</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>On our first episode of this seasons The Breakthrough, we talk with WNYCs Robert Lewis tells us how his reporting triggered an internal investigation of suspicious dealings made by active-duty New York police officers.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726764/11_RobertLewis-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/the-breakthrough-uncovering-nyc-cops-making-millions-in-suspicious-deals#133325</link>
<pubDate>Fri, 16 Jun 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-uncovering-nyc-cops-making-millions-in-suspicious-deals#133325</guid>
<description><![CDATA[
<p>On our first episode of this seasons The Breakthrough, we talk with WNYCs Robert Lewis tells us how his reporting triggered an internal investigation of suspicious dealings made by active-duty New York police officers.</p>
]]></description>
<dc:subject/>
<dc:date>2017-06-16T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
On our first episode of this seasons The Breakthrough, we talk with WNYCs Robert Lewis tells us how his reporting triggered an internal investigation of suspicious dealings made by active-duty New…</itunes:subtitle>
</item>
<item>
<title>Our Podcast, The Breakthrough, Is Back</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>In January, we launched The Breakthrough, which tells the stories behind investigative reporting. And were about to start a new season, on June 16.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/6726765/10_breakthrough_promo-CRAFT.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/our-podcast-the-breakthrough-is-back#133309</link>
<pubDate>Fri, 09 Jun 2017 12:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/our-podcast-the-breakthrough-is-back#133309</guid>
<description><![CDATA[
<p>In January, we launched The Breakthrough, which tells the stories behind investigative reporting. And were about to start a new season, on June 16.</p>
]]></description>
<dc:subject/>
<dc:date>2017-06-09T12:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
In January, we launched The Breakthrough, which tells the stories behind investigative reporting. And were about to start a new season, on June 16.
</itunes:subtitle>
</item>
<item>
<title>We Want Your Thoughts on Our Podcast</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary>We recently relaunched our podcast, in which journalists tell us how they nailed their biggest stories. Now we want to hear from you.</itunes:summary>
<enclosure url="http://tracking.feedpress.it/link/10581/5486888/9_Breakthrough_Survey.mp3" length="" type="audio/mpeg"/>
<itunes:duration/>
<link>https://www.propublica.org/podcast/we-want-your-thoughts-on-our-podcast#106727</link>
<pubDate>Sat, 11 Mar 2017 00:54:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/we-want-your-thoughts-on-our-podcast#106727</guid>
<description><![CDATA[
<p>We recently relaunched our podcast, in which journalists tell us how they nailed their biggest stories. Now we want to hear from you.</p>
]]></description>
<dc:subject/>
<dc:date>2017-03-11T00:54:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
We recently relaunched our podcast, in which journalists tell us how they nailed their biggest stories. Now we want to hear from you.
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: How Reporters Really Use Unnamed Sources</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<itunes:duration>00:16:27</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-how-reporters-really-use-unnamed-sources#66520</link>
<pubDate>Fri, 24 Feb 2017 14:00:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-how-reporters-really-use-unnamed-sources#66520</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-02-24T14:00:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Uncovering the FBIs Secret Rules</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<itunes:duration>00:20:28</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-uncovering-the-fbis-secret-rules#66523</link>
<pubDate>Fri, 17 Feb 2017 17:40:57 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-uncovering-the-fbis-secret-rules#66523</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-02-17T17:40:57+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Reporters Examine Murder Where Cops Struggle to Curb It</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<itunes:duration>00:20:01</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-reporters-examine-murder-where-cops-struggle-to-curb-it#66526</link>
<pubDate>Fri, 10 Feb 2017 17:25:36 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-reporters-examine-murder-where-cops-struggle-to-curb-it#66526</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-02-10T17:25:36+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: What American Journalists Can Learn From Reporting Under Putin</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<itunes:duration>00:31:54</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-what-american-journalists-can-learn-reporting-under-putin#66529</link>
<pubDate>Fri, 03 Feb 2017 14:00:40 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-what-american-journalists-can-learn-reporting-under-putin#66529</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-02-03T14:00:40+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Uncovering Danger at the Pharmacy Counter</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<itunes:duration>00:16:38</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-uncovering-danger-at-the-pharmacy-counter#66532</link>
<pubDate>Fri, 27 Jan 2017 17:03:24 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-uncovering-danger-at-the-pharmacy-counter#66532</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-01-27T17:03:24+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: How a Reporter Solved a Decades-Old Murder</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/6726766/4_Breakthrough_ MSKiller.mp3" length="56458604" type="audio/mpeg"/>
<itunes:duration>23:31</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-how-a-reporter-solved-a-decades-old-murder#66537</link>
<pubDate>Fri, 20 Jan 2017 14:00:14 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-how-a-reporter-solved-a-decades-old-murder#66537</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-01-20T14:00:14+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: Meet the Reporter Who Went Undercover in the Hermit Kingdom</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/5133080/3_Breakthrough_SukiKim.mp3" length="71396156" type="audio/mpeg"/>
<itunes:duration>00:29:44</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-meet-the-reporter-went-undercover-in-the-hermit-kingdom#66540</link>
<pubDate>Fri, 13 Jan 2017 14:00:24 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-meet-the-reporter-went-undercover-in-the-hermit-kingdom#66540</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-01-13T14:00:24+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>The Breakthrough: The $2 Drug Test</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/5094042/2_Breakthrough_DrugTest.mp3" length="63031678" type="audio/mpeg"/>
<itunes:duration>00:26:17</itunes:duration>
<link>https://www.propublica.org/podcast/the-breakthrough-the-2-dollar-drug-test#66543</link>
<pubDate>Fri, 06 Jan 2017 22:22:00 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/the-breakthrough-the-2-dollar-drug-test#66543</guid>
<description><![CDATA[
]]></description>
<dc:subject>Busted</dc:subject>
<dc:date>2017-01-06T22:22:00+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>Introducing Our New Podcast: The Breakthrough</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/5094043/1_TheBreakthrough_Promo.mp3" length="4910060" type="audio/mpeg"/>
<itunes:duration>00:02:03</itunes:duration>
<link>https://www.propublica.org/podcast/introducing-our-new-podcast-the-breakthrough#66546</link>
<pubDate>Fri, 06 Jan 2017 22:21:18 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/introducing-our-new-podcast-the-breakthrough#66546</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2017-01-06T22:21:18+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>Renewable Energy: An Exxon Investigation Given Second Life as Trump Taps Exec for Cabinet</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/5023664/52_NeelaBanerjee.mp3" length="34999184" type="audio/mpeg"/>
<itunes:duration>00:14:35</itunes:duration>
<link>https://www.propublica.org/podcast/renewable-energy-an-exxon-investigation-given-second-life-as-trump-taps-exe#66549</link>
<pubDate>Fri, 23 Dec 2016 21:57:02 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/renewable-energy-an-exxon-investigation-given-second-life-as-trump-taps-exe#66549</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2016-12-23T21:57:02+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
<item>
<title>How We Found a Pro-Trump Group Blew Past Campaign Finance Laws</title>
<itunes:author>ProPublica</itunes:author>
<itunes:summary/>
<enclosure url="http://tracking.feedpress.it/link/10581/4984992/51_Kate_Robert.mp3" length="34152423" type="audio/mpeg"/>
<itunes:duration>00:14:14</itunes:duration>
<link>https://www.propublica.org/podcast/how-we-found-a-pro-trump-group-blew-past-campaign-finance-laws#66552</link>
<pubDate>Fri, 16 Dec 2016 22:45:08 +0000</pubDate>
<guid isPermaLink="false">https://www.propublica.org/podcast/how-we-found-a-pro-trump-group-blew-past-campaign-finance-laws#66552</guid>
<description><![CDATA[
]]></description>
<dc:subject/>
<dc:date>2016-12-16T22:45:08+00:00</dc:date>
<dc:creator>ProPublica</dc:creator>
<itunes:explicit>no</itunes:explicit>
<itunes:keywords>journalism, news, investigative journalism, interview, propublica, media, behind the scenes</itunes:keywords>
<itunes:subtitle>
</itunes:subtitle>
</item>
</channel>
</rss>

View File

@ -0,0 +1,34 @@
# Snapshots of RSS feeds taken with InternetArchive's wayback machine.
## Links
#### Intercepted
Web view: https://web.archive.org/web/20180120083840/https://feeds.feedburner.com/InterceptedWithJeremyScahill
Raw file: https://web.archive.org/web/20180120083840if_/https://feeds.feedburner.com/InterceptedWithJeremyScahill
#### The TipOff
Web view: https://web.archive.org/web/20180120110727/https://rss.acast.com/thetipoff
Raw file: https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff
#### Linux Unplugged
Web view: https://web.archive.org/web/20180120110314/https://feeds.feedburner.com/linuxunplugged
Raw file: https://web.archive.org/web/20180120110314if_/https://feeds.feedburner.com/linuxunplugged
#### Steal the stars
Web view: https://web.archive.org/web/20180120104957/https://rss.art19.com/steal-the-stars
Raw file: https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars
#### Greater than Code
Web view: https://web.archive.org/web/20180120104741/https://www.greaterthancode.com/feed/podcast
Raw file: https://web.archive.org/web/20180120104741if_/https://www.greaterthancode.com/feed/podcast

View File

@ -6,15 +6,13 @@ workspace = "../"
[dependencies]
error-chain = "0.11.0"
hyper = "0.11.7"
log = "0.3.8"
hyper = "0.11.15"
log = "0.4.1"
mime_guess = "1.8.3"
reqwest = "0.8.1"
reqwest = "0.8.4"
tempdir = "0.3.5"
[dependencies.diesel]
features = ["sqlite"]
version = "0.99"
glob = "0.2.11"
[dependencies.hammond-data]
path = "../hammond-data"

View File

@ -1,19 +1,28 @@
use reqwest;
use glob::glob;
use hyper::header::*;
use tempdir::TempDir;
use mime_guess;
use reqwest;
use tempdir::TempDir;
use std::fs;
use std::fs::{rename, DirBuilder, File};
use std::io::{BufWriter, Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use errors::*;
use hammond_data::{Episode, Podcast};
use hammond_data::xdg_dirs::{DL_DIR, HAMMOND_CACHE};
use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery, Save};
use hammond_data::xdg_dirs::HAMMOND_CACHE;
// TODO: Replace path that are of type &str with std::path.
// TODO: Have a convention/document absolute/relative paths, if they should end with / or not.
pub trait DownloadProgress {
fn set_downloaded(&mut self, downloaded: u64);
fn set_size(&mut self, bytes: u64);
fn should_cancel(&self) -> bool;
}
// Adapted from https://github.com/mattgathu/rget .
// I never wanted to write a custom downloader.
// Sorry to those who will have to work with that code.
@ -21,7 +30,12 @@ use hammond_data::xdg_dirs::{DL_DIR, HAMMOND_CACHE};
// or bindings for a lib like youtube-dl(python),
// But cant seem to find one.
// TODO: Write unit-tests.
fn download_into(dir: &str, file_title: &str, url: &str) -> Result<String> {
fn download_into(
dir: &str,
file_title: &str,
url: &str,
progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<String> {
info!("GET request to: {}", url);
let client = reqwest::Client::builder().referer(false).build()?;
let mut resp = client.get(url).send()?;
@ -32,22 +46,28 @@ fn download_into(dir: &str, file_title: &str, url: &str) -> Result<String> {
}
let headers = resp.headers().clone();
let ct_len = headers.get::<ContentLength>().map(|ct_len| **ct_len);
let ct_type = headers.get::<ContentType>();
ct_len.map(|x| info!("File Lenght: {}", x));
ct_type.map(|x| info!("Content Type: {}", x));
let ext = get_ext(ct_type.cloned()).unwrap_or(String::from("unkown"));
let ext = get_ext(ct_type.cloned()).unwrap_or_else(|| String::from("unknown"));
info!("Extension: {}", ext);
// Construct a temp file to save desired content.
let tempdir = TempDir::new_in(dir, "")?;
// It has to be a `new_in` instead of new cause rename can't move cross filesystems.
let tempdir = TempDir::new_in(HAMMOND_CACHE.to_str().unwrap(), "temp_download")?;
let out_file = format!("{}/temp.part", tempdir.path().to_str().unwrap(),);
ct_len.map(|x| {
if let Some(p) = progress.clone() {
let mut m = p.lock().unwrap();
m.set_size(x);
}
});
// Save requested content into the file.
save_io(&out_file, &mut resp, ct_len)?;
save_io(&out_file, &mut resp, ct_len, progress)?;
// Construct the desired path.
let target = format!("{}/{}.{}", dir, file_title, ext);
@ -57,7 +77,7 @@ fn download_into(dir: &str, file_title: &str, url: &str) -> Result<String> {
Ok(target)
}
// Determine the file extension from the http content-type header.
/// Determine the file extension from the http content-type header.
fn get_ext(content: Option<ContentType>) -> Option<String> {
let cont = content.clone()?;
content
@ -72,8 +92,14 @@ fn get_ext(content: Option<ContentType>) -> Option<String> {
}
// TODO: Write unit-tests.
// TODO: Refactor... Somehow.
/// Handles the I/O of fetching a remote file and saving into a Buffer and A File.
fn save_io(file: &str, resp: &mut reqwest::Response, content_lenght: Option<u64>) -> Result<()> {
fn save_io(
file: &str,
resp: &mut reqwest::Response,
content_lenght: Option<u64>,
progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<()> {
info!("Downloading into: {}", file);
let chunk_size = match content_lenght {
Some(x) => x as usize / 99,
@ -88,6 +114,19 @@ fn save_io(file: &str, resp: &mut reqwest::Response, content_lenght: Option<u64>
buffer.truncate(bcount);
if !buffer.is_empty() {
writer.write_all(buffer.as_slice())?;
// This sucks.
// Actually the whole download module is hack, so w/e.
if let Some(prog) = progress.clone() {
let len = writer.get_ref().metadata().map(|x| x.len());
if let Ok(l) = len {
if let Ok(mut m) = prog.lock() {
if m.should_cancel() {
bail!("Download was cancelled.");
}
m.set_downloaded(l);
}
}
}
} else {
break;
}
@ -96,17 +135,12 @@ fn save_io(file: &str, resp: &mut reqwest::Response, content_lenght: Option<u64>
Ok(())
}
pub fn get_download_folder(pd_title: &str) -> Result<String> {
// It might be better to make it a hash of the title
let download_fold = format!("{}/{}", DL_DIR.to_str().unwrap(), pd_title);
// Create the folder
DirBuilder::new().recursive(true).create(&download_fold)?;
Ok(download_fold)
}
// TODO: Refactor
pub fn get_episode(ep: &mut Episode, download_folder: &str) -> Result<()> {
pub fn get_episode(
ep: &mut EpisodeWidgetQuery,
download_folder: &str,
progress: Option<Arc<Mutex<DownloadProgress>>>,
) -> Result<()> {
// Check if its alrdy downloaded
if ep.local_uri().is_some() {
if Path::new(ep.local_uri().unwrap()).exists() {
@ -118,11 +152,23 @@ pub fn get_episode(ep: &mut Episode, download_folder: &str) -> Result<()> {
ep.save()?;
};
let res = download_into(download_folder, ep.title(), ep.uri().unwrap());
let res = download_into(
download_folder,
&ep.rowid().to_string(),
ep.uri().unwrap(),
progress,
);
if let Ok(path) = res {
// If download succedes set episode local_uri to dlpath.
ep.set_local_uri(Some(&path));
// Over-write episode lenght
let size = fs::metadata(path);
if let Ok(s) = size {
ep.set_length(Some(s.len() as i32))
};
ep.save()?;
Ok(())
} else {
@ -131,48 +177,39 @@ pub fn get_episode(ep: &mut Episode, download_folder: &str) -> Result<()> {
}
}
pub fn cache_image(pd: &Podcast) -> Option<String> {
pub fn cache_image(pd: &PodcastCoverQuery) -> Option<String> {
let url = pd.image_uri()?.to_owned();
if url == "" {
return None;
}
let download_fold = format!(
"{}{}",
HAMMOND_CACHE.to_str().unwrap(),
pd.title().to_owned()
);
let cache_download_fold = format!("{}{}", HAMMOND_CACHE.to_str()?, pd.title().to_owned());
// Hacky way
// TODO: make it so it returns the first cover.* file encountered.
// Use glob instead
let png = format!("{}/cover.png", download_fold);
let jpg = format!("{}/cover.jpg", download_fold);
let jpe = format!("{}/cover.jpe", download_fold);
let jpeg = format!("{}/cover.jpeg", download_fold);
if Path::new(&png).exists() {
return Some(png);
} else if Path::new(&jpe).exists() {
return Some(jpe);
} else if Path::new(&jpg).exists() {
return Some(jpg);
} else if Path::new(&jpeg).exists() {
return Some(jpeg);
// Weird glob magic.
if let Ok(mut foo) = glob(&format!("{}/cover.*", cache_download_fold)) {
// For some reason there is no .first() method so nth(0) is used
let path = foo.nth(0).and_then(|x| x.ok());
if let Some(p) = path {
return Some(p.to_str()?.into());
}
};
// Create the folders if they don't exist.
DirBuilder::new()
.recursive(true)
.create(&download_fold)
.unwrap();
.create(&cache_download_fold)
.ok()?;
let dlpath = download_into(&download_fold, "cover", &url);
if let Ok(path) = dlpath {
info!("Cached img into: {}", &path);
Some(path)
} else {
error!("Failed to get feed image.");
error!("Error: {}", dlpath.unwrap_err());
None
match download_into(&cache_download_fold, "cover", &url, None) {
Ok(path) => {
info!("Cached img into: {}", &path);
Some(path)
}
Err(err) => {
error!("Failed to get feed image.");
error!("Error: {}", err);
None
}
}
}
@ -180,38 +217,28 @@ pub fn cache_image(pd: &Podcast) -> Option<String> {
mod tests {
use super::*;
use hammond_data::Source;
use hammond_data::feed::index;
use hammond_data::dbqueries;
use diesel::associations::Identifiable;
use std::fs;
#[test]
fn test_get_dl_folder() {
let foo_ = format!("{}/{}", DL_DIR.to_str().unwrap(), "foo");
assert_eq!(get_download_folder("foo").unwrap(), foo_);
let _ = fs::remove_dir_all(foo_);
}
use hammond_data::pipeline;
#[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
#[ignore]
fn test_cache_image() {
let url = "http://www.newrustacean.com/feed.xml";
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
let source = Source::from_url(url).unwrap();
// Copy it's id
let sid = source.id().clone();
// Convert Source it into a Feed and index it
let feed = source.into_feed().unwrap();
index(vec![feed]);
let sid = source.id();
// Convert Source it into a future Feed and index it
pipeline::run(vec![source], true).unwrap();
// Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
let img_path = cache_image(&pd);
let foo_ = format!(
"{}{}/cover.png",
"{}{}/cover.jpeg",
HAMMOND_CACHE.to_str().unwrap(),
pd.title()
);

View File

@ -1,13 +1,11 @@
use diesel::result;
use reqwest;
use hammond_data;
use reqwest;
use std::io;
error_chain! {
foreign_links {
ReqError(reqwest::Error);
IoError(io::Error);
DieselResultError(result::Error);
DataError(hammond_data::errors::Error);
}
}

View File

@ -1,8 +1,9 @@
#![recursion_limit = "1024"]
#![deny(unused_extern_crates, unused)]
extern crate diesel;
#[macro_use]
extern crate error_chain;
extern crate glob;
extern crate hammond_data;
extern crate hyper;
#[macro_use]

View File

@ -6,20 +6,20 @@ version = "0.1.0"
workspace = "../"
[dependencies]
chrono = "0.4.0"
dissolve = "0.2.2"
gdk = "0.7.0"
gdk-pixbuf = "0.3.0"
gio = "0.3.0"
glib = "0.4.0"
log = "0.3.8"
loggerv = "0.6.0"
glib = "0.4.1"
humansize = "1.1.0"
lazy_static = "1.0.0"
log = "0.4.1"
loggerv = "0.7.0"
open = "1.2.1"
rayon = "0.9.0"
regex = "0.2.3"
[dependencies.diesel]
features = ["sqlite"]
version = "0.99.0"
send-cell = "0.1.2"
url = "1.6.0"
[dependencies.gtk]
features = ["v3_22"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

View File

@ -1,7 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="empty_view">
<property name="visible">True</property>
<property name="can_focus">False</property>

View File

@ -1,201 +1,314 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="episode_box">
<property name="width_request">100</property>
<property name="height_request">25</property>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="episode_container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkButton" id="play_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">end</property>
<property name="valign">center</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-media-play</property>
<property name="use_fallback">True</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="download_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">end</property>
<property name="valign">center</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="always_show_image">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-save</property>
<property name="use_fallback">True</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="delete_button">
<property name="name">delete_button</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">end</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-delete</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="mark_unplayed_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Mark episode as Unplayed.</property>
<property name="halign">end</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-undo</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="mark_played_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Mark episode as played.</property>
<property name="halign">end</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-apply</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="title_label">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="use_markup">True</property>
<property name="wrap">True</property>
<property name="ellipsize">end</property>
<property name="lines">1</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkExpander" id="expand_desc">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="label_fill">True</property>
<property name="valign">start</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="min_content_height">100</property>
<property name="max_content_height">600</property>
<property name="propagate_natural_width">True</property>
<property name="propagate_natural_height">True</property>
<child>
<object class="GtkTextView" id="desc_text_view">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="margin_bottom">5</property>
<property name="editable">False</property>
<property name="wrap_mode">word-char</property>
<property name="cursor_visible">False</property>
</object>
</child>
</object>
</child>
<child type="label">
<object class="GtkLabel">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Description:</property>
<property name="halign">start</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Episode Title</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property>
<property name="max_width_chars">64</property>
<property name="track_visited_links">False</property>
<property name="lines">1</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="date_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">3 Jan</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="separator1">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">·</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="duration_label">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">42 min</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="separator2">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">·</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="local_size">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">0 MB</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="prog_separator">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">/</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">5</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="total_size">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">XX MB</property>
<property name="single_line_mode">True</property>
<property name="track_visited_links">False</property>
<style>
<class name="dim-label"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="download_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Download this episode</property>
<property name="halign">end</property>
<property name="valign">center</property>
<property name="always_show_image">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">document-save-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="play_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Play this episode</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">media-playback-start-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">6</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkProgressBar" id="progress_bar">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="pulse_step">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
<property name="padding">6</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>

View File

@ -0,0 +1,383 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="name">container</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="frame_parent">
<property name="width_request">720</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkBox" id="today_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Today</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="today_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="yday_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Yesterday</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="yday_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="week_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Week</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="week_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="month_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">This Month</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="month_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkBox" id="rest_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Older</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1.5"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkListBox" id="rest_list">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="selection_mode">none</property>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">5</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="pixel_size">64</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
</interface>

View File

@ -1,20 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkPopover" id="add-popover">
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkPopover" id="add_popover">
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Add a new feed</property>
<property name="valign">center</property>
<property name="relative_to">add_toggle</property>
<property name="position">bottom</property>
<child>
<object class="GtkBox" id="add-box">
<object class="GtkBox" id="add_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="margin_left">6</property>
<property name="margin_right">6</property>
<property name="margin_top">6</property>
<property name="margin_bottom">6</property>
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="add-box-enter-address-label">
<object class="GtkLabel" id="enter_address_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
@ -31,12 +65,12 @@
</packing>
</child>
<child>
<object class="GtkBox" id="add-entry-box">
<object class="GtkBox" id="add_entry_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">6</property>
<child>
<object class="GtkEntry" id="new-url">
<object class="GtkEntry" id="new_url">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="width_chars">30</property>
@ -49,14 +83,15 @@
</packing>
</child>
<child>
<object class="GtkStack" id="add-button-stack">
<object class="GtkStack" id="add_button_stack">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="interpolate_size">True</property>
<child>
<object class="GtkButton" id="add-button">
<object class="GtkButton" id="add_button">
<property name="label" translatable="yes">Add</property>
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<style>
@ -95,7 +130,7 @@
</packing>
</child>
<child>
<object class="GtkLabel" id="add-box-already-subscribed-label">
<object class="GtkLabel" id="result_label">
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">You are already subscribed to that feed!</property>
@ -109,67 +144,130 @@
</object>
</child>
</object>
<object class="GtkHeaderBar" id="headerbar1">
<property name="visible">True</property>
<object class="GtkHeaderBar" id="headerbar">
<property name="can_focus">False</property>
<property name="title">Hammond</property>
<property name="has_subtitle">False</property>
<property name="show_close_button">True</property>
<child>
<object class="GtkButton" id="homebutton">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
<property name="always_show_image">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-home</property>
<property name="use_fallback">True</property>
</object>
</child>
</object>
<packing>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkButton" id="refbutton">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
<property name="use_underline">True</property>
<property name="always_show_image">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-refresh</property>
<property name="use_fallback">True</property>
</object>
</child>
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkMenuButton" id="add-toggle-button">
<object class="GtkMenuButton" id="add_toggle">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="tooltip_text" translatable="yes">Add a new feed</property>
<property name="valign">center</property>
<child>
<object class="GtkImage" id="add-button-image">
<object class="GtkImage" id="add-button-image2">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-add</property>
<property name="use_fallback">True</property>
<property name="icon_name">list-add-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
<style>
<class name="image-button"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="back_button">
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="no_show_all">True</property>
<property name="tooltip_text" translatable="yes">Back</property>
<property name="valign">center</property>
<child>
<object class="GtkImage" id="back-button-image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">go-previous-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
<style>
<class name="image-button"/>
</style>
</object>
<packing>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="update_notification">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<child>
<object class="GtkSpinner" id="update_spinner">
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">6</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="update_label">
<property name="can_focus">False</property>
<property name="label" translatable="yes">Fetching new episodes</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="position">3</property>
</packing>
</child>
<child type="title">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkStackSwitcher" id="switch">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="show_title">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="label" translatable="yes">Show Title</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<child>
<object class="GtkMenuButton" id="menu_toggle">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="valign">center</property>
<property name="popover">menu_popover</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">open-menu-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
@ -179,8 +277,102 @@
</object>
<packing>
<property name="pack_type">end</property>
<property name="position">3</property>
<property name="position">2</property>
</packing>
</child>
</object>
<object class="GtkPopover" id="menu_popover">
<property name="can_focus">False</property>
<property name="relative_to">menu_toggle</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Preferences</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="update_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="text" translatable="yes">Check for new episodes</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can_focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkModelButton" id="about_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">About</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Help</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">7</property>
</packing>
</child>
<child>
<object class="GtkModelButton">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="text" translatable="yes">Keyboard Shortcuts</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">8</property>
</packing>
</child>
</object>
</child>
</object>
</interface>

View File

@ -1,203 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<interface domain="gnome-music">
<requires lib="gtk+" version="3.12"/>
<object class="GtkBox" id="podcast_widget">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="margin_left">32</property>
<property name="margin_right">32</property>
<property name="margin_start">32</property>
<property name="margin_end">32</property>
<property name="margin_top">64</property>
<property name="margin_bottom">32</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<property name="spacing">15</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_left">1</property>
<property name="margin_right">1</property>
<property name="margin_start">1</property>
<property name="margin_end">1</property>
<property name="stock">gtk-missing-image</property>
<property name="use_fallback">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<child>
<object class="GtkLabel" id="title_label">
<property name="width_request">50</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="label" translatable="yes">Foobar</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="wrap">True</property>
<property name="max_width_chars">28</property>
<property name="track_visited_links">False</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</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="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>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Unsubrscribe from this Podcast.
Warn: This will delete downloaded content associated with this Podcast.</property>
<property name="stock">gtk-delete</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="mark_all_played_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Mark all episodes as Played.</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-apply</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="min_content_width">200</property>
<property name="max_content_width">200</property>
<property name="propagate_natural_width">True</property>
<property name="propagate_natural_height">True</property>
<child>
<object class="GtkTextView" id="desc_text_view">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">False</property>
<property name="wrap_mode">word-char</property>
<property name="cursor_visible">False</property>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport" id="view">
<property name="width_request">400</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="shadow_type">none</property>
<child>
<placeholder/>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="fb_child">
<property name="width_request">256</property>
<property name="height_request">256</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkOverlay">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<placeholder/>
</child>
<child type="overlay">
<object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="stock">gtk-missing-image</property>
<property name="use_fallback">True</property>
<property name="icon_size">6</property>
</object>
</child>
<child type="overlay">
<object class="GtkImage" id="banner">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="halign">end</property>
<property name="valign">start</property>
<property name="stock">gtk-missing-image</property>
</object>
<packing>
<property name="index">1</property>
</packing>
</child>
<child type="overlay">
<object class="GtkLabel" id="banner_label">
<property name="can_focus">False</property>
<property name="no_show_all">True</property>
<property name="halign">end</property>
<property name="valign">start</property>
<property name="margin_right">40</property>
<property name="margin_top">38</property>
<property name="label" translatable="yes">Num</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="track_visited_links">False</property>
<property name="xalign">1</property>
<property name="yalign">0</property>
</object>
<packing>
<property name="index">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="pd_title">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="label" translatable="yes">label</property>
<property name="use_markup">True</property>
<property name="justify">center</property>
<property name="ellipsize">end</property>
<property name="single_line_mode">True</property>
<property name="max_width_chars">33</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>

View File

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.1 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkBox" id="fb_parent">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport" id="viewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkFlowBox" id="flowbox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">baseline</property>
<property name="valign">start</property>
<property name="homogeneous">True</property>
<property name="column_spacing">5</property>
<property name="row_spacing">2</property>
<property name="max_children_per_line">20</property>
<property name="selection_mode">none</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -0,0 +1,282 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface domain="">
<requires lib="gtk+" version="3.12"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="container">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="width_request">624</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="spacing">24</property>
<child>
<object class="GtkFrame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="spacing">12</property>
<child>
<object class="GtkImage" id="cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">128</property>
<property name="icon_name">image-x-generic-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child type="center">
<object class="GtkLabel" id="description">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">start</property>
<property name="valign">end</property>
<property name="label" translatable="yes">Show description</property>
<property name="wrap">True</property>
<property name="max_width_chars">57</property>
<attributes>
<attribute name="weight" value="medium"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkMenuButton" id="settings_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon_name">emblem-system-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="link_button">
<property name="label" translatable="yes">Website</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="unsub_button">
<property name="label" translatable="yes">Unsubscribe</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<style>
<class name="destructive-action"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">5</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="pack_type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="episodes">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label_xalign">0</property>
<property name="shadow_type">in</property>
<child>
<placeholder/>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_child">
<property name="width_request">256</property>
<property name="height_request">256</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkOverlay">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<placeholder/>
</child>
<child type="overlay">
<object class="GtkImage" id="pd_cover">
<property name="visible">True</property>
<property name="can_focus">False</property>
<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>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.21.0
Copyright (C) 2017 - 2018
This file is part of Hammond.
Hammond is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Hammond is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Hammond. If not, see <http://www.gnu.org/licenses/>.
Authors:
Jordan Petridis
Tobias Bernard
-->
<interface>
<requires lib="gtk+" version="3.20"/>
<!-- interface-license-type gplv3 -->
<!-- interface-name Hammond -->
<!-- interface-description A podcast client for the GNOME Desktop -->
<!-- interface-copyright 2017 - 2018 -->
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
<object class="GtkBox" id="fb_parent">
<property name="name">fb_parent</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="name">scrolled_window</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkFlowBox" id="flowbox">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin_top">24</property>
<property name="margin_bottom">24</property>
<property name="homogeneous">True</property>
<property name="column_spacing">12</property>
<property name="row_spacing">12</property>
<property name="max_children_per_line">20</property>
<property name="selection_mode">none</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@ -0,0 +1,11 @@
row {
border-bottom: solid 1px rgba(0,0,0, 0.1);
}
row:last-child {
border-bottom: none;
}
list, border {
border-radius: 4px;
}

View File

@ -4,8 +4,8 @@
<name>Hammond</name>
<project_license>GPL-3.0</project_license>
<metadata_license>CC0-1.0</metadata_license>
<developer_name>Daniel García Moreno</developer_name>
<summary>Gtk+ Matrix.org client</summary>
<developer_name>Jordan Petridis</developer_name>
<summary>GNOME Podcast Client written in Rust</summary>
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
<description>
Hammond is a Fast, Safe and Reliable Gtk+ Podcast client written in Rust
@ -17,7 +17,7 @@
</screenshot>
</screenshots>
<releases>
<release version="0.1.1" date="2017-11-13"/>
<release version="0.2.0" date="2017-11-28"/>
</releases>
<update_contact>jordanpetridis@protonmail.com</update_contact>
</component>

View File

@ -1,12 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/org/gnome/hammond/">
<file>banner.png</file>
<file preprocess="xml-stripblanks">gtk/episode_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/podcast_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/show_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/empty_view.ui</file>
<file preprocess="xml-stripblanks">gtk/podcasts_view.ui</file>
<file preprocess="xml-stripblanks">gtk/podcasts_child.ui</file>
<file preprocess="xml-stripblanks">gtk/episodes_view.ui</file>
<file preprocess="xml-stripblanks">gtk/episodes_view_widget.ui</file>
<file preprocess="xml-stripblanks">gtk/shows_view.ui</file>
<file preprocess="xml-stripblanks">gtk/shows_child.ui</file>
<file preprocess="xml-stripblanks">gtk/headerbar.ui</file>
<file compressed="true">gtk/style.css</file>
</gresource>
</gresources>

159
hammond-gtk/src/app.rs Normal file
View File

@ -0,0 +1,159 @@
use gio::{ApplicationExt, ApplicationExtManual, ApplicationFlags};
use glib;
use gtk;
use gtk::prelude::*;
use hammond_data::{Podcast, Source};
use hammond_data::utils::checkup;
use content::Content;
use headerbar::Header;
use utils;
use std::sync::Arc;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::time::Duration;
#[derive(Clone, Debug)]
pub enum Action {
UpdateSources(Option<Source>),
RefreshAllViews,
RefreshEpisodesView,
RefreshEpisodesViewBGR,
RefreshShowsView,
RefreshWidget,
RefreshWidgetIfVis,
ReplaceWidget(Podcast),
RefreshWidgetIfSame(i32),
ShowWidgetAnimated,
ShowShowsAnimated,
HeaderBarShowTile(String),
HeaderBarNormal,
HeaderBarShowUpdateIndicator,
HeaderBarHideUpdateIndicator,
}
#[derive(Debug)]
pub struct App {
app_instance: gtk::Application,
window: gtk::Window,
header: Arc<Header>,
content: Arc<Content>,
receiver: Receiver<Action>,
sender: Sender<Action>,
}
impl App {
pub fn new() -> App {
let application = gtk::Application::new("org.gnome.Hammond", ApplicationFlags::empty())
.expect("Initialization failed...");
// Weird magic I copy-pasted that sets the Application Name in the Shell.
glib::set_application_name("Hammond");
glib::set_prgname(Some("Hammond"));
// Create the main window
let window = gtk::Window::new(gtk::WindowType::Toplevel);
window.set_default_size(860, 640);
window.set_title("Hammond");
let app_clone = application.clone();
window.connect_delete_event(move |_, _| {
app_clone.quit();
Inhibit(false)
});
let (sender, receiver) = channel();
// Create a content instance
let content = Arc::new(Content::new(sender.clone()));
// Create the headerbar
let header = Arc::new(Header::new(content.clone(), &window, sender.clone()));
// Add the content main stack to the window.
window.add(&content.get_stack());
App {
app_instance: application,
window,
header,
content,
receiver,
sender,
}
}
pub fn setup_timed_callbacks(&self) {
let sender = self.sender.clone();
// Update the feeds right after the Application is initialized.
gtk::timeout_add_seconds(2, move || {
utils::refresh_feed(None, sender.clone());
glib::Continue(false)
});
let sender = self.sender.clone();
// Auto-updater, runs every hour.
// TODO: expose the interval in which it run to a user setting.
gtk::timeout_add_seconds(3600, move || {
utils::refresh_feed(None, sender.clone());
glib::Continue(true)
});
// Run a database checkup once the application is initialized.
gtk::timeout_add(300, || {
let _ = checkup();
glib::Continue(false)
});
}
pub fn run(self) {
let window = self.window.clone();
let app = self.app_instance.clone();
self.app_instance.connect_startup(move |_| {
build_ui(&window, &app);
});
self.setup_timed_callbacks();
let content = self.content.clone();
let headerbar = self.header.clone();
let sender = self.sender.clone();
let receiver = self.receiver;
gtk::idle_add(move || {
match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(Action::UpdateSources(source)) => {
if let Some(s) = source {
utils::refresh_feed(Some(vec![s]), sender.clone())
} else {
utils::refresh_feed(None, sender.clone())
}
}
Ok(Action::RefreshAllViews) => content.update(),
Ok(Action::RefreshShowsView) => content.update_shows_view(),
Ok(Action::RefreshWidget) => content.update_widget(),
Ok(Action::RefreshWidgetIfVis) => content.update_widget_if_visible(),
Ok(Action::RefreshWidgetIfSame(id)) => content.update_widget_if_same(id),
Ok(Action::RefreshEpisodesView) => content.update_episode_view(),
Ok(Action::RefreshEpisodesViewBGR) => content.update_episode_view_if_baground(),
Ok(Action::ReplaceWidget(ref pd)) => content.get_shows().replace_widget(pd),
Ok(Action::ShowWidgetAnimated) => content.get_shows().switch_widget_animated(),
Ok(Action::ShowShowsAnimated) => content.get_shows().switch_podcasts_animated(),
Ok(Action::HeaderBarShowTile(title)) => headerbar.switch_to_back(&title),
Ok(Action::HeaderBarNormal) => headerbar.switch_to_normal(),
Ok(Action::HeaderBarShowUpdateIndicator) => headerbar.show_update_notification(),
Ok(Action::HeaderBarHideUpdateIndicator) => headerbar.hide_update_notification(),
Err(_) => (),
}
Continue(true)
});
ApplicationExtManual::run(&self.app_instance, &[]);
}
}
fn build_ui(window: &gtk::Window, app: &gtk::Application) {
window.set_application(app);
window.show_all();
window.activate();
app.connect_activate(move |_| ());
}

View File

@ -1,269 +1,310 @@
use gtk;
use gtk::Cast;
use gtk::prelude::*;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use widgets::podcast::PodcastWidget;
use views::podcasts::PopulatedView;
use views::empty::EmptyView;
use views::episodes::EpisodesView;
use views::shows::ShowsPopulated;
#[derive(Debug)]
use app::Action;
use widgets::show::ShowWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
pub struct Content {
pub stack: gtk::Stack,
pub widget: PodcastWidget,
pub podcasts: PopulatedView,
pub empty: EmptyView,
stack: gtk::Stack,
shows: Arc<ShowStack>,
episodes: Arc<EpisodeStack>,
sender: Sender<Action>,
}
impl Content {
pub fn new() -> Content {
pub fn new(sender: Sender<Action>) -> Content {
let stack = gtk::Stack::new();
let episodes = Arc::new(EpisodeStack::new(sender.clone()));
let shows = Arc::new(ShowStack::new(sender.clone()));
let widget = PodcastWidget::new();
let podcasts = PopulatedView::new();
let empty = EmptyView::new();
stack.add_named(&widget.container, "widget");
stack.add_named(&podcasts.container, "podcasts");
stack.add_named(&empty.container, "empty");
stack.add_titled(&episodes.stack, "episodes", "Episodes");
stack.add_titled(&shows.stack, "shows", "Shows");
Content {
stack,
widget,
empty,
podcasts,
shows,
episodes,
sender,
}
}
pub fn new_initialized() -> Content {
let ct = Content::new();
ct.init();
ct
pub fn update(&self) {
self.update_episode_view();
self.update_shows_view();
self.update_widget()
}
pub fn init(&self) {
self.podcasts.init(&self.stack);
if self.podcasts.flowbox.get_children().is_empty() {
pub fn update_episode_view(&self) {
self.episodes.update();
}
pub fn update_episode_view_if_baground(&self) {
if self.stack.get_visible_child_name() != Some("episodes".into()) {
self.episodes.update();
}
}
pub fn update_shows_view(&self) {
self.shows.update_podcasts();
}
pub fn update_widget(&self) {
self.shows.update_widget();
}
pub fn update_widget_if_same(&self, pid: i32) {
self.shows.update_widget_if_same(pid);
}
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.shows.update_widget();
}
}
pub fn get_stack(&self) -> gtk::Stack {
self.stack.clone()
}
pub fn get_shows(&self) -> Arc<ShowStack> {
self.shows.clone()
}
}
#[derive(Debug, Clone)]
pub struct ShowStack {
stack: gtk::Stack,
sender: Sender<Action>,
}
impl ShowStack {
fn new(sender: Sender<Action>) -> ShowStack {
let stack = gtk::Stack::new();
let show = ShowStack {
stack,
sender: sender.clone(),
};
let pop = ShowsPopulated::new(sender.clone());
let widget = ShowWidget::default();
let empty = EmptyView::new();
show.stack.add_named(&pop.container, "podcasts");
show.stack.add_named(&widget.container, "widget");
show.stack.add_named(&empty.container, "empty");
if pop.is_empty() {
show.stack.set_visible_child_name("empty")
} else {
show.stack.set_visible_child_name("podcasts")
}
show
}
// pub fn update(&self) {
// self.update_widget();
// self.update_podcasts();
// }
pub fn update_podcasts(&self) {
let vis = self.stack.get_visible_child_name().unwrap();
let old = self.stack
.get_child_by_name("podcasts")
// This is guaranted to exists, based on `ShowStack::new()`.
.unwrap()
.downcast::<gtk::Box>()
// This is guaranted to be a Box based on the `ShowsPopulated` impl.
.unwrap();
debug!("Name: {:?}", WidgetExt::get_name(&old));
let scrolled_window = old.get_children()
.first()
// This is guaranted to exist based on the show_widget.ui file.
.unwrap()
.clone()
.downcast::<gtk::ScrolledWindow>()
// This is guaranted based on the show_widget.ui file.
.unwrap();
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();
}
pub fn replace_widget(&self, pd: &Podcast) {
let old = self.stack
.get_child_by_name("widget")
// This is guaranted to exists, based on `ShowStack::new()`.
.unwrap()
.downcast::<gtk::Box>()
// This is guaranted to be a Box based on the `ShowWidget` impl.
.unwrap();
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()
// This is guaranted to exist based on the show_widget.ui file.
.unwrap()
.clone()
.downcast::<gtk::ScrolledWindow>()
// This is guaranted based on the show_widget.ui file.
.unwrap();
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");
}
pub fn update_widget(&self) {
let vis = self.stack.get_visible_child_name().unwrap();
let old = self.stack.get_child_by_name("widget").unwrap();
let id = WidgetExt::get_name(&old);
if id == Some("GtkBox".to_string()) || id.is_none() {
return;
}
self.stack.set_visible_child_name("podcasts");
let pd = dbqueries::get_podcast_from_id(id.unwrap().parse::<i32>().unwrap());
if let Ok(pd) = pd {
self.replace_widget(&pd);
self.stack.set_visible_child_name(&vis);
old.destroy();
}
}
fn replace_widget(&mut self, pdw: PodcastWidget) {
let vis = self.stack.get_visible_child_name().unwrap();
// Only update widget if it's podcast_id is equal to pid.
pub fn update_widget_if_same(&self, pid: i32) {
let old = self.stack.get_child_by_name("widget").unwrap();
self.stack.remove(&old);
self.widget = pdw;
self.stack.add_named(&self.widget.container, "widget");
self.stack.set_visible_child_name(&vis);
old.destroy();
}
fn replace_podcasts(&mut self, pop: PopulatedView) {
let vis = self.stack.get_visible_child_name().unwrap();
let old = self.stack.get_child_by_name("podcasts").unwrap();
self.stack.remove(&old);
self.podcasts = pop;
self.stack.add_named(&self.podcasts.container, "podcasts");
self.stack.set_visible_child_name(&vis);
old.destroy();
}
}
#[derive(Debug)]
// Experiementing with Wrapping gtk::Stack into a State machine.
// Gonna revist it when TryInto trais is stabilized.
pub struct ContentState<S> {
content: Content,
state: S,
}
pub trait UpdateView {
fn update(&mut self);
}
#[derive(Debug)]
pub struct Empty {}
#[derive(Debug)]
pub struct PodcastsView {}
#[derive(Debug)]
pub struct WidgetsView {}
impl Into<ContentState<PodcastsView>> for ContentState<Empty> {
fn into(self) -> ContentState<PodcastsView> {
self.content.stack.set_visible_child_name("podcasts");
ContentState {
content: self.content,
state: PodcastsView {},
let id = WidgetExt::get_name(&old);
if id != Some(pid.to_string()) || id.is_none() {
return;
}
}
}
impl UpdateView for ContentState<Empty> {
fn update(&mut self) {}
}
impl Into<ContentState<Empty>> for ContentState<PodcastsView> {
fn into(self) -> ContentState<Empty> {
self.content.stack.set_visible_child_name("empty");
ContentState {
content: self.content,
state: Empty {},
}
}
}
impl Into<ContentState<WidgetsView>> for ContentState<PodcastsView> {
fn into(self) -> ContentState<WidgetsView> {
self.content.stack.set_visible_child_name("widget");
ContentState {
content: self.content,
state: WidgetsView {},
}
}
}
impl UpdateView for ContentState<PodcastsView> {
fn update(&mut self) {
let pop = PopulatedView::new_initialized(&self.content.stack);
self.content.replace_podcasts(pop)
}
}
impl Into<ContentState<PodcastsView>> for ContentState<WidgetsView> {
fn into(self) -> ContentState<PodcastsView> {
self.content.stack.set_visible_child_name("podcasts");
ContentState {
content: self.content,
state: PodcastsView {},
}
}
}
impl Into<ContentState<Empty>> for ContentState<WidgetsView> {
fn into(self) -> ContentState<Empty> {
self.content.stack.set_visible_child_name("empty");
ContentState {
content: self.content,
state: Empty {},
}
}
}
impl UpdateView for ContentState<WidgetsView> {
fn update(&mut self) {
let old = self.content.stack.get_child_by_name("widget").unwrap();
let id = WidgetExt::get_name(&old).unwrap();
let pd = dbqueries::get_podcast_from_id(id.parse::<i32>().unwrap()).unwrap();
let pdw = PodcastWidget::new_initialized(&self.content.stack, &pd);
self.content.replace_widget(pdw);
}
}
impl ContentState<PodcastsView> {
#[allow(dead_code)]
pub fn new() -> Result<ContentState<PodcastsView>, ContentState<Empty>> {
let content = Content::new();
content.podcasts.init(&content.stack);
if content.podcasts.flowbox.get_children().is_empty() {
content.stack.set_visible_child_name("empty");
return Err(ContentState {
content,
state: Empty {},
});
}
content.stack.set_visible_child_name("podcasts");
Ok(ContentState {
content,
state: PodcastsView {},
})
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)
}
#[allow(dead_code)]
pub fn get_stack(&self) -> gtk::Stack {
self.content.stack.clone()
self.stack.clone()
}
}
fn replace_widget(stack: &gtk::Stack, pdw: &PodcastWidget) {
let old = stack.get_child_by_name("widget").unwrap();
stack.remove(&old);
stack.add_named(&pdw.container, "widget");
old.destroy();
#[derive(Debug, Clone)]
pub struct EpisodeStack {
stack: gtk::Stack,
sender: Sender<Action>,
}
fn replace_podcasts(stack: &gtk::Stack, pop: &PopulatedView) {
let old = stack.get_child_by_name("podcasts").unwrap();
stack.remove(&old);
stack.add_named(&pop.container, "podcasts");
old.destroy();
}
impl EpisodeStack {
fn new(sender: Sender<Action>) -> EpisodeStack {
let episodes = EpisodesView::new(sender.clone());
let empty = EmptyView::new();
let stack = gtk::Stack::new();
#[allow(dead_code)]
pub fn show_widget(stack: &gtk::Stack) {
stack.set_visible_child_name("widget")
}
stack.add_named(&episodes.container, "episodes");
stack.add_named(&empty.container, "empty");
pub fn show_podcasts(stack: &gtk::Stack) {
stack.set_visible_child_name("podcasts")
}
if episodes.is_empty() {
stack.set_visible_child_name("empty");
} else {
stack.set_visible_child_name("episodes");
}
pub fn show_empty(stack: &gtk::Stack) {
stack.set_visible_child_name("empty")
}
pub fn update_podcasts(stack: &gtk::Stack) {
let pods = PopulatedView::new_initialized(stack);
if pods.flowbox.get_children().is_empty() {
show_empty(stack)
EpisodeStack { stack, sender }
}
replace_podcasts(stack, &pods);
}
pub fn update(&self) {
let old = self.stack
.get_child_by_name("episodes")
// This is guaranted to exists, based on `EpisodeStack::new()`.
.unwrap()
.downcast::<gtk::Box>()
// This is guaranted to be a Box based on the `EpisodesView` impl.
.unwrap();
debug!("Name: {:?}", WidgetExt::get_name(&old));
pub fn update_widget(stack: &gtk::Stack, pd: &Podcast) {
let pdw = PodcastWidget::new_initialized(stack, pd);
replace_widget(stack, &pdw);
}
let scrolled_window = old.get_children()
.first()
// This is guaranted to exist based on the episodes_view.ui file.
.unwrap()
.clone()
.downcast::<gtk::ScrolledWindow>()
// This is guaranted based on the episodes_view.ui file.
.unwrap();
debug!("Name: {:?}", WidgetExt::get_name(&scrolled_window));
pub fn update_podcasts_preserve_vis(stack: &gtk::Stack) {
let vis = stack.get_visible_child_name().unwrap();
update_podcasts(stack);
if vis != "empty" {
stack.set_visible_child_name(&vis)
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();
}
}
pub fn update_widget_preserve_vis(stack: &gtk::Stack, pd: &Podcast) {
let vis = stack.get_visible_child_name().unwrap();
update_widget(stack, pd);
stack.set_visible_child_name(&vis)
}
pub fn on_podcasts_child_activate(stack: &gtk::Stack, pd: &Podcast) {
update_widget(stack, pd);
stack.set_visible_child_full("widget", gtk::StackTransitionType::SlideLeft);
}
pub fn on_home_button_activate(stack: &gtk::Stack) {
let vis = stack.get_visible_child_name().unwrap();
if vis != "widget" {
update_podcasts(stack);
}
show_podcasts(stack);
}

View File

@ -2,86 +2,219 @@ use gtk;
use gtk::prelude::*;
use hammond_data::Source;
use hammond_data::utils::url_cleaner;
use hammond_data::dbqueries;
use url::Url;
use utils;
use content;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug)]
use app::Action;
use content::Content;
#[derive(Debug, Clone)]
pub struct Header {
pub container: gtk::HeaderBar,
home: gtk::Button,
refresh: gtk::Button,
container: gtk::HeaderBar,
add_toggle: gtk::MenuButton,
switch: gtk::StackSwitcher,
back_button: gtk::Button,
show_title: gtk::Label,
about_button: gtk::ModelButton,
update_button: gtk::ModelButton,
update_box: gtk::Box,
update_label: gtk::Label,
update_spinner: gtk::Spinner,
}
impl Header {
pub fn new() -> Header {
impl Default for Header {
fn default() -> Header {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let header: gtk::HeaderBar = builder.get_object("headerbar1").unwrap();
let home: gtk::Button = builder.get_object("homebutton").unwrap();
let refresh: gtk::Button = builder.get_object("refbutton").unwrap();
let add_toggle: gtk::MenuButton = builder.get_object("add-toggle-button").unwrap();
let header: gtk::HeaderBar = builder.get_object("headerbar").unwrap();
let add_toggle: gtk::MenuButton = builder.get_object("add_toggle").unwrap();
let switch: gtk::StackSwitcher = builder.get_object("switch").unwrap();
let back_button: gtk::Button = builder.get_object("back_button").unwrap();
let show_title: gtk::Label = builder.get_object("show_title").unwrap();
let update_button: gtk::ModelButton = builder.get_object("update_button").unwrap();
let update_box: gtk::Box = builder.get_object("update_notification").unwrap();
let update_label: gtk::Label = builder.get_object("update_label").unwrap();
let update_spinner: gtk::Spinner = builder.get_object("update_spinner").unwrap();
let about_button: gtk::ModelButton = builder.get_object("about_button").unwrap();
Header {
container: header,
home,
refresh,
add_toggle,
switch,
back_button,
show_title,
about_button,
update_button,
update_box,
update_label,
update_spinner,
}
}
pub fn new_initialized(stack: &gtk::Stack) -> Header {
let header = Header::new();
header.init(stack);
header
}
fn init(&self, stack: &gtk::Stack) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add-popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new-url").unwrap();
let add_button: gtk::Button = builder.get_object("add-button").unwrap();
new_url.connect_changed(move |url| {
println!("{:?}", url.get_text());
});
add_button.connect_clicked(clone!(stack, add_popover, new_url => move |_| {
on_add_bttn_clicked(&stack, &new_url);
// TODO: lock the button instead of hiding and add notification of feed added.
// TODO: map the spinner
add_popover.hide();
}));
self.add_toggle.set_popover(&add_popover);
// TODO: make it a back arrow button, that will hide when appropriate,
// and add a StackSwitcher when more views are added.
self.home.connect_clicked(clone!(stack => move |_| {
content::on_home_button_activate(&stack);
}));
// FIXME: There appears to be a memmory leak here.
self.refresh.connect_clicked(clone!(stack => move |_| {
utils::refresh_feed(&stack, None, None);
}));
}
}
fn on_add_bttn_clicked(stack: &gtk::Stack, entry: &gtk::Entry) {
impl Header {
pub fn new(content: Arc<Content>, window: &gtk::Window, sender: Sender<Action>) -> Header {
let h = Header::default();
h.init(content, window, sender);
h
}
pub fn init(&self, content: Arc<Content>, window: &gtk::Window, sender: Sender<Action>) {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/headerbar.ui");
let add_popover: gtk::Popover = builder.get_object("add_popover").unwrap();
let new_url: gtk::Entry = builder.get_object("new_url").unwrap();
let add_button: gtk::Button = builder.get_object("add_button").unwrap();
let result_label: gtk::Label = builder.get_object("result_label").unwrap();
self.switch.set_stack(&content.get_stack());
new_url.connect_changed(clone!(add_button => move |url| {
on_url_change(url, &result_label, &add_button);
}));
add_button.connect_clicked(clone!(add_popover, new_url, sender => move |_| {
on_add_bttn_clicked(&new_url, sender.clone());
add_popover.hide();
}));
self.add_toggle.set_popover(&add_popover);
self.update_button.connect_clicked(move |_| {
sender.send(Action::UpdateSources(None)).unwrap();
});
self.about_button
.connect_clicked(clone!(window => move |_| {
about_dialog(&window);
}));
// Add the Headerbar to the window.
window.set_titlebar(&self.container);
let switch = &self.switch;
let add_toggle = &self.add_toggle;
let show_title = &self.show_title;
self.back_button.connect_clicked(
clone!(content, switch, add_toggle, show_title => move |back| {
switch.show();
add_toggle.show();
back.hide();
show_title.hide();
content.get_shows().get_stack().set_visible_child_full("podcasts", gtk::StackTransitionType::SlideRight);
}),
);
}
pub fn switch_to_back(&self, title: &str) {
self.switch.hide();
self.add_toggle.hide();
self.back_button.show();
self.set_show_title(title);
self.show_title.show();
}
pub fn switch_to_normal(&self) {
self.switch.show();
self.add_toggle.show();
self.back_button.hide();
self.show_title.hide();
}
pub fn set_show_title(&self, title: &str) {
self.show_title.set_text(title)
}
pub fn show_update_notification(&self) {
self.update_spinner.start();
self.update_box.show();
self.update_spinner.show();
self.update_label.show();
}
pub fn hide_update_notification(&self) {
self.update_spinner.stop();
self.update_box.hide();
self.update_spinner.hide();
self.update_label.hide();
}
}
fn on_add_bttn_clicked(entry: &gtk::Entry, sender: Sender<Action>) {
let url = entry.get_text().unwrap_or_default();
let url = url_cleaner(&url);
let source = Source::from_url(&url);
if let Ok(s) = source {
info!("{:?} feed added", url);
// update the db
utils::refresh_feed(stack, Some(vec![s]), None);
if source.is_ok() {
entry.set_text("");
sender.send(Action::UpdateSources(source.ok())).unwrap();
} else {
error!("Feed probably already exists.");
error!("Something went wrong.");
error!("Error: {:?}", source.unwrap_err());
}
}
fn on_url_change(entry: &gtk::Entry, result: &gtk::Label, add_button: &gtk::Button) {
let uri = entry.get_text().unwrap();
debug!("Url: {}", uri);
let url = Url::parse(&uri);
// TODO: refactor to avoid duplication
match url {
Ok(u) => {
if !dbqueries::source_exists(u.as_str()).unwrap() {
add_button.set_sensitive(true);
result.hide();
result.set_label("");
} else {
add_button.set_sensitive(false);
result.set_label("Show already exists.");
result.show();
}
}
Err(err) => {
add_button.set_sensitive(false);
if !uri.is_empty() {
result.set_label("Invalid url.");
result.show();
error!("Error: {}", err);
} else {
result.hide();
}
}
}
}
// Totally copied it from fractal.
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
fn about_dialog(window: &gtk::Window) {
// Feel free to add yourself if you contribured.
let authors = &[
"Jordan Petridis",
"Julian Sparber",
"Gabriele Musco",
"Constantin Nickel",
];
let dialog = gtk::AboutDialog::new();
// Waiting for a logo.
dialog.set_logo_icon_name("org.gnome.Hammond");
dialog.set_comments("A Podcast Client for the GNOME Desktop.");
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
dialog.set_license_type(gtk::License::Gpl30);
dialog.set_modal(true);
// TODO: make it show it fetches the commit hash from which it was built
// and the version number is kept in sync automaticly
dialog.set_version("0.3");
dialog.set_program_name("Hammond");
// TODO: Need a wiki page first.
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
// dialog.set_website_label("Learn more about Hammond");
dialog.set_transient_for(window);
dialog.set_artists(&["Tobias Bernard"]);
dialog.set_authors(authors);
dialog.show();
}

View File

@ -1,26 +1,31 @@
#![cfg_attr(feature = "cargo-clippy", allow(clone_on_ref_ptr, needless_pass_by_value))]
#![deny(unused_extern_crates, unused)]
extern crate gdk;
extern crate gdk_pixbuf;
extern crate gio;
extern crate glib;
extern crate gtk;
extern crate diesel;
extern crate chrono;
extern crate dissolve;
extern crate hammond_data;
extern crate hammond_downloader;
extern crate humansize;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate log;
extern crate loggerv;
extern crate open;
extern crate regex;
extern crate send_cell;
extern crate url;
// extern crate rayon;
// use rayon::prelude::*;
use log::LogLevel;
use hammond_data::utils::checkup;
use log::Level;
use gtk::prelude::*;
use gio::{ActionMapExt, ApplicationExt, MenuExt, SimpleActionExt};
// http://gtk-rs.org/tuto/closures
#[macro_export]
@ -45,86 +50,34 @@ mod views;
mod widgets;
mod headerbar;
mod content;
mod app;
mod utils;
mod manager;
mod static_resource;
/*
THIS IS STILL A PROTOTYPE.
*/
fn build_ui(app: &gtk::Application) {
let menu = gio::Menu::new();
menu.append("Quit", "app.quit");
menu.append("Checkup", "app.check");
app.set_app_menu(&menu);
// Get the main window
let window = gtk::ApplicationWindow::new(app);
window.set_default_size(1150, 650);
// TODO: this will blow horribly
// let ct = content::ContentState::new().unwrap();
// let stack = ct.get_stack();
let ct = content::Content::new_initialized();
let stack = ct.stack;
window.add(&stack);
window.connect_delete_event(|w, _| {
w.destroy();
Inhibit(false)
});
// Setup quit in the app menu since default is overwritten.
let quit = gio::SimpleAction::new("quit", None);
let window2 = window.clone();
quit.connect_activate(move |_, _| {
window2.destroy();
});
app.add_action(&quit);
// Setup the checkup in the app menu.
let check = gio::SimpleAction::new("check", None);
check.connect_activate(move |_, _| {
let _ = checkup();
});
app.add_action(&check);
// queue a db update 1 minute after the startup.
gtk::idle_add(clone!(stack => move || {
utils::refresh_feed(&stack, None, Some(60));
glib::Continue(false)
}));
gtk::idle_add(move || {
let _ = checkup();
glib::Continue(false)
});
// Get the headerbar
let header = headerbar::Header::new_initialized(&stack);
window.set_titlebar(&header.container);
window.show_all();
window.activate();
app.connect_activate(move |_| ());
}
use app::App;
fn main() {
use gio::ApplicationExtManual;
// TODO: make the the logger a cli -vv option
loggerv::init_with_level(LogLevel::Info).unwrap();
loggerv::init_with_level(Level::Info).unwrap();
gtk::init().expect("Error initializing gtk");
static_resource::init().expect("Something went wrong with the resource file initialization.");
let application = gtk::Application::new("org.gnome.Hammond", gio::ApplicationFlags::empty())
.expect("Initialization failed...");
// Add custom style
let provider = gtk::CssProvider::new();
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css");
gtk::StyleContext::add_provider_for_screen(
&gdk::Screen::get_default().unwrap(),
&provider,
600,
);
application.connect_startup(move |app| {
build_ui(app);
});
// This set's the app to dark mode.
// It wiil be in the user's preference later.
// Uncomment it to run with the dark theme variant.
// let settings = gtk::Settings::get_default().unwrap();
// settings.set_property_gtk_application_prefer_dark_theme(true);
// application.run(&[]);
ApplicationExtManual::run(&application, &[]);
App::new().run();
}

162
hammond-gtk/src/manager.rs Normal file
View File

@ -0,0 +1,162 @@
// use hammond_data::Episode;
use hammond_data::dbqueries;
use hammond_downloader::downloader::{get_episode, DownloadProgress};
use app::Action;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use std::sync::mpsc::Sender;
// use std::sync::atomic::AtomicUsize;
// use std::path::PathBuf;
use std::thread;
#[derive(Debug)]
pub struct Progress {
total_bytes: u64,
downloaded_bytes: u64,
cancel: bool,
}
impl Default for Progress {
fn default() -> Self {
Progress {
total_bytes: 0,
downloaded_bytes: 0,
cancel: false,
}
}
}
impl Progress {
pub fn get_fraction(&self) -> f64 {
let ratio = self.downloaded_bytes as f64 / self.total_bytes as f64;
debug!("{:?}", self);
debug!("Ratio completed: {}", ratio);
if ratio >= 1.0 {
return 1.0;
};
ratio
}
pub fn get_total_size(&self) -> u64 {
self.total_bytes
}
pub fn get_downloaded(&self) -> u64 {
self.downloaded_bytes
}
pub fn cancel(&mut self) {
self.cancel = true;
}
}
impl DownloadProgress for Progress {
fn set_downloaded(&mut self, downloaded: u64) {
self.downloaded_bytes = downloaded
}
fn set_size(&mut self, bytes: u64) {
self.total_bytes = bytes;
}
fn should_cancel(&self) -> bool {
self.cancel
}
}
lazy_static! {
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> = {
Arc::new(RwLock::new(HashMap::new()))
};
}
pub fn add(id: i32, directory: &str, sender: Sender<Action>) {
// Create a new `Progress` struct to keep track of dl progress.
let prog = Arc::new(Mutex::new(Progress::default()));
{
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
m.insert(id, prog.clone());
}
}
let dir = directory.to_owned();
thread::spawn(move || {
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
let pid = episode.podcast_id();
let id = episode.rowid();
get_episode(&mut episode.into(), dir.as_str(), Some(prog))
.err()
.map(|err| {
error!("Error while trying to download an episode");
error!("Error: {}", err);
});
{
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
info!("Removed: {:?}", m.remove(&id));
}
}
// {
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
// }
// }
sender.send(Action::RefreshEpisodesView).unwrap();
sender.send(Action::RefreshWidgetIfSame(pid)).unwrap();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use hammond_data::{Episode, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_data::utils::get_download_folder;
use std::{thread, time};
use std::path::Path;
use std::sync::mpsc::channel;
#[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
#[ignore]
// THIS IS NOT A RELIABLE TEST
// Just quick sanity check
fn test_start_dl() {
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
let source = Source::from_url(url).unwrap();
// Copy it's id
let sid = source.id();
pipeline::run(vec![source], true).unwrap();
// Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let title = "Coming soon... The Tip Off";
// Get an episode
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
let (sender, _rx) = channel();
let download_fold = get_download_folder(&pd.title()).unwrap();
add(episode.rowid(), download_fold.as_str(), sender);
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
// Give it soem time to download the file
thread::sleep(time::Duration::from_secs(20));
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
println!("{}", &final_path);
assert!(Path::new(&final_path).exists());
}
}

View File

@ -1,112 +1,94 @@
use glib;
use gtk;
use gdk_pixbuf::Pixbuf;
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
use hammond_data::feed;
use hammond_data::{Podcast, Source};
use gdk_pixbuf::Pixbuf;
use send_cell::SendCell;
// use hammond_data::feed;
use hammond_data::{PodcastCoverQuery, Source};
use hammond_data::dbqueries;
use hammond_data::pipeline;
use hammond_downloader::downloader;
use std::{thread, time};
use std::cell::RefCell;
use std::sync::mpsc::{channel, Receiver};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use std::sync::mpsc::Sender;
use std::thread;
use content;
use regex::Regex;
use app::Action;
type Foo = RefCell<Option<(gtk::Stack, Receiver<bool>)>>;
// Create a thread local storage that will store the arguments to be transfered.
thread_local!(static GLOBAL: Foo = RefCell::new(None));
/// 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.
/// `delay` represents the desired time in seconds for the thread to sleep before executing.
/// When It's done,it queues up a `podcast_view` refresh.
pub fn refresh_feed(stack: &gtk::Stack, source: Option<Vec<Source>>, delay: Option<u64>) {
// Create a async channel.
let (sender, receiver) = channel();
// Pass the desired arguments into the Local Thread Storage.
GLOBAL.with(clone!(stack => move |global| {
*global.borrow_mut() = Some((stack, receiver));
}));
/// When It's done,it queues up a `RefreshViews` action.
pub fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) {
sender.send(Action::HeaderBarShowUpdateIndicator).unwrap();
thread::spawn(move || {
if let Some(s) = delay {
let t = time::Duration::from_secs(s);
thread::sleep(t);
let sources = source.unwrap_or_else(|| dbqueries::get_sources().unwrap());
if let Err(err) = pipeline::run(sources, false) {
error!("Error While trying to update the database.");
error!("Error msg: {}", err);
}
let feeds = {
if let Some(vec) = source {
Ok(feed::fetch(vec))
} else {
feed::fetch_all()
}
};
if let Ok(x) = feeds {
feed::index(x);
sender.send(true).expect("Couldn't send data to channel");;
glib::idle_add(refresh_podcasts_view);
};
sender.send(Action::HeaderBarHideUpdateIndicator).unwrap();
sender.send(Action::RefreshAllViews).unwrap();
});
}
fn refresh_podcasts_view() -> glib::Continue {
GLOBAL.with(|global| {
if let Some((ref stack, ref reciever)) = *global.borrow() {
if reciever.try_recv().is_ok() {
content::update_podcasts_preserve_vis(stack);
}
}
});
glib::Continue(false)
lazy_static! {
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> = {
RwLock::new(HashMap::new())
};
}
pub fn get_pixbuf_from_path(pd: &Podcast) -> Option<Pixbuf> {
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
// use the cover of the Podcast Feed/Show, We can only create a Pixbuf
// cover per show and pass around the Rc pointer.
//
// GObjects do not implement Send trait, so SendCell is a way around that.
// Also lazy_static requires Sync trait, so that's what the mutexes are.
// TODO: maybe use something that would just scale to requested size?
pub fn get_pixbuf_from_path(pd: &PodcastCoverQuery, size: u32) -> Option<Pixbuf> {
{
let hashmap = CACHED_PIXBUFS.read().unwrap();
let res = hashmap.get(&(pd.id(), size));
if let Some(px) = res {
let m = px.lock().unwrap();
return Some(m.clone().into_inner());
}
}
let img_path = downloader::cache_image(pd)?;
Pixbuf::new_from_file_at_scale(&img_path, 256, 256, true).ok()
}
#[allow(dead_code)]
// WIP: parse html to markup
pub fn html_to_markup(s: &mut str) -> Cow<str> {
s.trim();
s.replace('&', "&amp;");
s.replace('<', "&lt;");
s.replace('>', "&gt;");
let re = Regex::new("(?P<url>https?://[^\\s&,)(\"]+(&\\w=[\\w._-]?)*(#[\\w._-]+)?)").unwrap();
re.replace_all(s, "<a href=\"$url\">$url</a>")
let px = Pixbuf::new_from_file_at_scale(&img_path, size as i32, size as i32, true).ok();
if let Some(px) = px {
let mut hashmap = CACHED_PIXBUFS.write().unwrap();
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
return Some(px);
}
None
}
#[cfg(test)]
mod tests {
use hammond_data::Source;
use hammond_data::feed::index;
use hammond_data::dbqueries;
use diesel::associations::Identifiable;
use super::*;
use hammond_data::Source;
use hammond_data::dbqueries;
#[test]
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
// to run it.
#[ignore]
fn test_get_pixbuf_from_path() {
let url = "http://www.newrustacean.com/feed.xml";
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
// Create and index a source
let source = Source::from_url(url).unwrap();
// Copy it's id
let sid = source.id().clone();
// Convert Source it into a Feed and index it
let feed = source.into_feed().unwrap();
index(vec![feed]);
let sid = source.id();
pipeline::run(vec![source], true).unwrap();
// Get the Podcast
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
let pxbuf = get_pixbuf_from_path(&pd);
let pxbuf = get_pixbuf_from_path(&pd.into(), 256);
assert!(pxbuf.is_some());
}
}

View File

@ -5,11 +5,17 @@ pub struct EmptyView {
pub container: gtk::Box,
}
impl EmptyView {
pub fn new() -> EmptyView {
impl Default for EmptyView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_view.ui");
let view: gtk::Box = builder.get_object("empty_view").unwrap();
EmptyView { container: view }
}
}
impl EmptyView {
pub fn new() -> EmptyView {
EmptyView::default()
}
}

View File

@ -0,0 +1,220 @@
use chrono::prelude::*;
use gtk;
use gtk::prelude::*;
use hammond_data::EpisodeWidgetQuery;
use hammond_data::dbqueries;
use app::Action;
use utils::get_pixbuf_from_path;
use widgets::episode::EpisodeWidget;
use std::sync::Arc;
use std::sync::mpsc::Sender;
#[derive(Debug, Clone)]
enum ListSplit {
Today,
Yday,
Week,
Month,
Rest,
}
#[derive(Debug, Clone)]
pub struct EpisodesView {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
frame_parent: gtk::Box,
today_box: gtk::Box,
yday_box: gtk::Box,
week_box: gtk::Box,
month_box: gtk::Box,
rest_box: gtk::Box,
today_list: gtk::ListBox,
yday_list: gtk::ListBox,
week_list: gtk::ListBox,
month_list: gtk::ListBox,
rest_list: gtk::ListBox,
}
impl Default for EpisodesView {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
let today_box: gtk::Box = builder.get_object("today_box").unwrap();
let yday_box: gtk::Box = builder.get_object("yday_box").unwrap();
let week_box: gtk::Box = builder.get_object("week_box").unwrap();
let month_box: gtk::Box = builder.get_object("month_box").unwrap();
let rest_box: gtk::Box = builder.get_object("rest_box").unwrap();
let today_list: gtk::ListBox = builder.get_object("today_list").unwrap();
let yday_list: gtk::ListBox = builder.get_object("yday_list").unwrap();
let week_list: gtk::ListBox = builder.get_object("week_list").unwrap();
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
EpisodesView {
container,
scrolled_window,
frame_parent,
today_box,
yday_box,
week_box,
month_box,
rest_box,
today_list,
yday_list,
week_list,
month_list,
rest_list,
}
}
}
// TODO: REFACTOR ME
impl EpisodesView {
pub fn new(sender: Sender<Action>) -> Arc<EpisodesView> {
let view = EpisodesView::default();
let episodes = dbqueries::get_episodes_widgets_with_limit(50).unwrap();
let now_utc = Utc::now();
episodes.into_iter().for_each(|mut ep| {
let viewep = EpisodesViewWidget::new(&mut ep, sender.clone());
let t = split(&now_utc, i64::from(ep.epoch()));
match t {
ListSplit::Today => {
view.today_list.add(&viewep.container);
}
ListSplit::Yday => {
view.yday_list.add(&viewep.container);
}
ListSplit::Week => {
view.week_list.add(&viewep.container);
}
ListSplit::Month => {
view.month_list.add(&viewep.container);
}
ListSplit::Rest => {
view.rest_list.add(&viewep.container);
}
}
});
if view.today_list.get_children().is_empty() {
view.today_box.hide();
}
if view.yday_list.get_children().is_empty() {
view.yday_box.hide();
}
if view.week_list.get_children().is_empty() {
view.week_box.hide();
}
if view.month_list.get_children().is_empty() {
view.month_box.hide();
}
if view.rest_list.get_children().is_empty() {
view.rest_box.hide();
}
view.container.show_all();
Arc::new(view)
}
pub fn is_empty(&self) -> bool {
if !self.today_list.get_children().is_empty() {
return false;
}
if !self.yday_list.get_children().is_empty() {
return false;
}
if !self.week_list.get_children().is_empty() {
return false;
}
if !self.month_list.get_children().is_empty() {
return false;
}
if !self.rest_list.get_children().is_empty() {
return false;
}
true
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
let ep = Utc.timestamp(epoch, 0);
if now.ordinal() == ep.ordinal() && now.year() == ep.year() {
ListSplit::Today
} else if now.ordinal() == ep.ordinal() + 1 && now.year() == ep.year() {
ListSplit::Yday
} else if now.iso_week().week() == ep.iso_week().week() && now.year() == ep.year() {
ListSplit::Week
} else if now.month() == ep.month() && now.year() == ep.year() {
ListSplit::Month
} else {
ListSplit::Rest
}
}
#[derive(Debug, Clone)]
struct EpisodesViewWidget {
container: gtk::Box,
image: gtk::Image,
episode: gtk::Box,
}
impl Default for EpisodesViewWidget {
fn default() -> Self {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
let ep = EpisodeWidget::default();
container.pack_start(&ep.container, true, true, 6);
EpisodesViewWidget {
container,
image,
episode: ep.container,
}
}
}
impl EpisodesViewWidget {
fn new(episode: &mut EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodesViewWidget {
let builder =
gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let image: gtk::Image = builder.get_object("cover").unwrap();
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(episode.podcast_id()) {
get_pixbuf_from_path(&pd, 64).map(|img| image.set_from_pixbuf(&img));
}
let ep = EpisodeWidget::new(episode, sender.clone());
container.pack_start(&ep.container, true, true, 6);
EpisodesViewWidget {
container,
image,
episode: ep.container,
}
}
}

View File

@ -1,2 +1,3 @@
pub mod podcasts;
pub mod shows;
pub mod episodes;
pub mod empty;

View File

@ -1,144 +0,0 @@
use gtk;
use gtk::prelude::*;
use gdk_pixbuf::Pixbuf;
use diesel::associations::Identifiable;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use utils::get_pixbuf_from_path;
use content;
#[derive(Debug, Clone)]
pub struct PopulatedView {
pub container: gtk::Box,
pub flowbox: gtk::FlowBox,
viewport: gtk::Viewport,
}
#[derive(Debug)]
struct PodcastChild {
container: gtk::Box,
title: gtk::Label,
cover: gtk::Image,
banner: gtk::Image,
number: gtk::Label,
child: gtk::FlowBoxChild,
}
impl PopulatedView {
pub fn new() -> PopulatedView {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/podcasts_view.ui");
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
let viewport: gtk::Viewport = builder.get_object("viewport").unwrap();
PopulatedView {
container,
flowbox,
viewport,
}
}
#[allow(dead_code)]
pub fn new_initialized(stack: &gtk::Stack) -> PopulatedView {
let pop = PopulatedView::new();
pop.init(stack);
pop
}
pub fn init(&self, stack: &gtk::Stack) {
use gtk::WidgetExt;
// TODO: handle unwraps.
self.flowbox
.connect_child_activated(clone!(stack => move |_, child| {
// This is such an ugly hack...
// let id = child.get_name().unwrap().parse::<i32>().unwrap();
let id = WidgetExt::get_name(child).unwrap().parse::<i32>().unwrap();
let parent = dbqueries::get_podcast_from_id(id).unwrap();
on_flowbox_child_activate(&stack, &parent);
}));
// Populate the flowbox with the Podcasts.
self.populate_flowbox();
}
fn populate_flowbox(&self) {
let podcasts = dbqueries::get_podcasts();
if let Ok(pds) = podcasts {
pds.iter().for_each(|parent| {
let flowbox_child = PodcastChild::new_initialized(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
}
}
}
impl PodcastChild {
fn new() -> PodcastChild {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/podcasts_child.ui");
// Copy of gnome-music AlbumWidget
let container: gtk::Box = builder.get_object("fb_child").unwrap();
let title: gtk::Label = builder.get_object("pd_title").unwrap();
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
let banner: gtk::Image = builder.get_object("banner").unwrap();
let number: gtk::Label = builder.get_object("banner_label").unwrap();
let child = gtk::FlowBoxChild::new();
child.add(&container);
PodcastChild {
container,
title,
cover,
banner,
number,
child,
}
}
fn init(&self, pd: &Podcast) {
self.title.set_text(pd.title());
let cover = get_pixbuf_from_path(pd);
if let Some(img) = cover {
self.cover.set_from_pixbuf(&img);
};
WidgetExt::set_name(&self.child, &pd.id().to_string());
self.configure_banner(pd);
}
pub fn new_initialized(pd: &Podcast) -> PodcastChild {
let child = PodcastChild::new();
child.init(pd);
child
}
fn configure_banner(&self, pd: &Podcast) {
let bann =
Pixbuf::new_from_resource_at_scale("/org/gnome/hammond/banner.png", 256, 256, true);
if let Ok(b) = bann {
self.banner.set_from_pixbuf(&b);
let new_episodes = dbqueries::get_pd_unplayed_episodes(pd);
if let Ok(n) = new_episodes {
if !n.is_empty() {
self.number.set_text(&n.len().to_string());
self.banner.show();
self.number.show();
}
}
}
}
}
fn on_flowbox_child_activate(stack: &gtk::Stack, parent: &Podcast) {
content::on_podcasts_child_activate(stack, parent)
}

View File

@ -0,0 +1,124 @@
use gtk;
use gtk::prelude::*;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use app::Action;
use utils::get_pixbuf_from_path;
use std::sync::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>) -> ShowsPopulated {
let pop = ShowsPopulated::default();
pop.init(sender);
pop
}
pub fn init(&self, sender: Sender<Action>) {
use gtk::WidgetExt;
// TODO: handle unwraps.
self.flowbox.connect_child_activated(move |_, child| {
// This is such an ugly hack...
let id = WidgetExt::get_name(child).unwrap().parse::<i32>().unwrap();
let pd = dbqueries::get_podcast_from_id(id).unwrap();
sender
.send(Action::HeaderBarShowTile(pd.title().into()))
.unwrap();
sender.send(Action::ReplaceWidget(pd)).unwrap();
sender.send(Action::ShowWidgetAnimated).unwrap();
});
// Populate the flowbox with the Podcasts.
self.populate_flowbox();
}
fn populate_flowbox(&self) {
let podcasts = dbqueries::get_podcasts();
if let Ok(pds) = podcasts {
pds.iter().for_each(|parent| {
let flowbox_child = ShowsChild::new(parent);
self.flowbox.add(&flowbox_child.child);
});
self.flowbox.show_all();
}
}
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)
}
}
#[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());
let cover = get_pixbuf_from_path(&pd.clone().into(), 256);
if let Some(img) = cover {
self.cover.set_from_pixbuf(&img);
};
WidgetExt::set_name(&self.child, &pd.id().to_string());
}
}

View File

@ -1,209 +1,283 @@
use glib;
use gtk;
use chrono::prelude::*;
use gtk::prelude::*;
use gtk::{ContainerExt, TextBufferExt};
use humansize::{file_size_opts as size_opts, FileSize};
use open;
use dissolve::strip_html_tags;
use hammond_data::{EpisodeWidgetQuery, Podcast};
use hammond_data::dbqueries;
use hammond_data::{Episode, Podcast};
use hammond_downloader::downloader;
use hammond_data::utils::*;
use hammond_data::errors::*;
use hammond_data::utils::replace_extra_spaces;
use hammond_data::utils::get_download_folder;
// use utils::html_to_markup;
use app::Action;
use manager;
use std::thread;
use std::cell::RefCell;
use std::sync::mpsc::{channel, Receiver};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::mpsc::Sender;
type Foo = RefCell<Option<(gtk::Button, gtk::Button, gtk::Button, Receiver<bool>)>>;
thread_local!(static GLOBAL: Foo = RefCell::new(None));
#[derive(Debug)]
struct EpisodeWidget {
container: gtk::Box,
download: gtk::Button,
play: gtk::Button,
delete: gtk::Button,
played: gtk::Button,
unplayed: gtk::Button,
title: gtk::Label,
description: gtk::TextView,
// description: gtk::Label,
expander: gtk::Expander,
lazy_static! {
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
// Declare a custom humansize option struct
// See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html
Arc::new(size_opts::FileSizeOpts {
divider: size_opts::Kilo::Binary,
units: size_opts::Kilo::Decimal,
decimal_places: 0,
decimal_zeroes: 0,
fixed_at: size_opts::FixedAt::No,
long_units: false,
space: true,
suffix: "",
allow_negative: false,
})
};
}
impl EpisodeWidget {
fn new() -> EpisodeWidget {
// This is just a prototype and will be reworked probably.
#[derive(Debug, Clone)]
pub struct EpisodeWidget {
pub container: gtk::Box,
play: gtk::Button,
download: gtk::Button,
cancel: gtk::Button,
title: gtk::Label,
date: gtk::Label,
duration: gtk::Label,
progress: gtk::ProgressBar,
total_size: gtk::Label,
local_size: gtk::Label,
separator1: gtk::Label,
separator2: gtk::Label,
prog_separator: gtk::Label,
}
impl Default for EpisodeWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episode_widget.ui");
let container: gtk::Box = builder.get_object("episode_box").unwrap();
let container: gtk::Box = builder.get_object("episode_container").unwrap();
let progress: gtk::ProgressBar = builder.get_object("progress_bar").unwrap();
let download: gtk::Button = builder.get_object("download_button").unwrap();
let play: gtk::Button = builder.get_object("play_button").unwrap();
let delete: gtk::Button = builder.get_object("delete_button").unwrap();
let played: gtk::Button = builder.get_object("mark_played_button").unwrap();
let unplayed: gtk::Button = builder.get_object("mark_unplayed_button").unwrap();
let cancel: gtk::Button = builder.get_object("cancel_button").unwrap();
let title: gtk::Label = builder.get_object("title_label").unwrap();
let expander: gtk::Expander = builder.get_object("expand_desc").unwrap();
let description: gtk::TextView = builder.get_object("desc_text_view").unwrap();
// let description: gtk::Label = builder.get_object("desc_text").unwrap();
let date: gtk::Label = builder.get_object("date_label").unwrap();
let duration: gtk::Label = builder.get_object("duration_label").unwrap();
let local_size: gtk::Label = builder.get_object("local_size").unwrap();
let total_size: gtk::Label = builder.get_object("total_size").unwrap();
let separator1: gtk::Label = builder.get_object("separator1").unwrap();
let separator2: gtk::Label = builder.get_object("separator2").unwrap();
let prog_separator: gtk::Label = builder.get_object("prog_separator").unwrap();
EpisodeWidget {
container,
progress,
download,
play,
delete,
played,
unplayed,
cancel,
title,
expander,
description,
duration,
date,
total_size,
local_size,
separator1,
separator2,
prog_separator,
}
}
}
pub fn new_initialized(episode: &mut Episode, pd: &Podcast) -> EpisodeWidget {
let widget = EpisodeWidget::new();
widget.init(episode, pd);
lazy_static! {
static ref NOW: DateTime<Utc> = Utc::now();
}
impl EpisodeWidget {
pub fn new(episode: &mut EpisodeWidgetQuery, sender: Sender<Action>) -> EpisodeWidget {
let widget = EpisodeWidget::default();
widget.init(episode, sender);
widget
}
fn init(&self, episode: &mut Episode, pd: &Podcast) {
self.title.set_xalign(0.0);
self.title.set_text(episode.title());
fn init(&self, episode: &mut EpisodeWidgetQuery, sender: Sender<Action>) {
WidgetExt::set_name(&self.container, &episode.rowid().to_string());
if episode.description().is_some() {
let text = episode.description().unwrap().to_owned();
let description = &self.description;
self.expander
.connect_activate(clone!(description, text => move |_| {
// let mut text = text.clone();
// html_to_markup(&mut text);
// description.set_markup(&text)
// Set the title label state.
self.set_title(episode);
let plain_text = strip_html_tags(&text).join(" ");
// TODO: handle unwrap
let buff = description.get_buffer().unwrap();
buff.set_text(&replace_extra_spaces(&plain_text));
}));
}
// Set the size label.
self.set_total_size(episode.length());
if episode.played().is_some() {
self.unplayed.show();
self.played.hide();
}
// Set the duaration label.
self.set_duration(episode.duration());
// Set the date label.
self.set_date(episode.epoch());
// Show or hide the play/delete/download buttons upon widget initialization.
let local_uri = episode.local_uri();
self.show_buttons(episode.local_uri());
// Determine what the state of the progress bar should be.
self.determine_progess_bar();
let title = &self.title;
self.play
.connect_clicked(clone!(episode, title, sender => move |_| {
let mut episode = episode.clone();
on_play_bttn_clicked(episode.rowid());
if episode.set_played_now().is_ok() {
title
.get_style_context()
.map(|c| c.add_class("dim-label"));
sender.send(Action::RefreshEpisodesViewBGR).unwrap();
};
}));
self.download
.connect_clicked(clone!(episode, sender => move |dl| {
dl.set_sensitive(false);
on_download_clicked(&episode, sender.clone());
}));
}
/// Show or hide the play/delete/download buttons upon widget initialization.
fn show_buttons(&self, local_uri: Option<&str>) {
if local_uri.is_some() && Path::new(local_uri.unwrap()).exists() {
self.download.hide();
self.play.show();
self.delete.show();
}
}
let played = &self.played;
let unplayed = &self.unplayed;
self.play
.connect_clicked(clone!(episode, played, unplayed => move |_| {
let mut episode = episode.clone();
on_play_bttn_clicked(episode.rowid());
let _ = episode.set_played_now();
played.hide();
unplayed.show();
}));
/// Determine the title state.
fn set_title(&self, episode: &EpisodeWidgetQuery) {
self.title.set_xalign(0.0);
self.title.set_text(episode.title());
let play = &self.play;
let download = &self.download;
self.delete
.connect_clicked(clone!(episode, play, download => move |del| {
on_delete_bttn_clicked(episode.rowid());
del.hide();
play.hide();
download.show();
}));
// Grey out the title if the episode is played.
if episode.played().is_some() {
self.title
.get_style_context()
.map(|c| c.add_class("dim-label"));
}
}
let unplayed = &self.unplayed;
self.played
.connect_clicked(clone!(episode, unplayed => move |played| {
let mut episode = episode.clone();
let _ = episode.set_played_now();
played.hide();
unplayed.show();
}));
/// Set the date label depending on the current time.
fn set_date(&self, epoch: i32) {
let date = Utc.timestamp(i64::from(epoch), 0);
if NOW.year() == date.year() {
self.date.set_text(date.format("%e %b").to_string().trim());
} else {
self.date
.set_text(date.format("%e %b %Y").to_string().trim());
};
}
let played = &self.played;
self.unplayed
.connect_clicked(clone!(episode, played => move |un| {
let mut episode = episode.clone();
episode.set_played(None);
let _ = episode.save();
un.hide();
played.show();
}));
/// Set the duration label.
fn set_duration(&self, seconds: Option<i32>) {
if (seconds == Some(0)) || seconds.is_none() {
return;
};
let pd_title = pd.title().to_owned();
let play = &self.play;
let delete = &self.delete;
self.download
.connect_clicked(clone!(play, delete, episode => move |dl| {
on_download_clicked(
&pd_title,
&mut episode.clone(),
dl,
&play,
&delete,
);
}));
if let Some(secs) = seconds {
self.duration.set_text(&format!("{} min", secs / 60));
self.duration.show();
self.separator1.show();
}
}
/// Set the Episode label dependings on its size
fn set_total_size(&self, bytes: Option<i32>) {
if let Some(size) = bytes {
if size != 0 {
size.file_size(SIZE_OPTS.clone()).ok().map(|s| {
self.total_size.set_text(&s);
self.total_size.show();
self.separator2.show();
});
}
};
}
// FIXME: REFACTOR ME
fn determine_progess_bar(&self) {
let id = WidgetExt::get_name(&self.container)
.unwrap()
.parse::<i32>()
.unwrap();
let prog_struct = || -> Option<_> {
if let Ok(m) = manager::ACTIVE_DOWNLOADS.read() {
if !m.contains_key(&id) {
return None;
};
return m.get(&id).cloned();
}
None
}();
let progress_bar = self.progress.clone();
let total_size = self.total_size.clone();
let local_size = self.local_size.clone();
if let Some(prog) = prog_struct {
self.download.hide();
self.progress.show();
self.local_size.show();
self.total_size.show();
self.separator2.show();
self.prog_separator.show();
self.cancel.show();
// Setup a callback that will update the progress bar.
update_progressbar_callback(prog.clone(), id, progress_bar, local_size);
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
update_total_size_callback(prog.clone(), total_size);
self.cancel.connect_clicked(clone!(prog => move |cancel| {
if let Ok(mut m) = prog.lock() {
m.cancel();
cancel.set_sensitive(false);
}
}));
}
}
}
// TODO: show notification when dl is finished.
fn on_download_clicked(
pd_title: &str,
ep: &mut Episode,
download_bttn: &gtk::Button,
play_bttn: &gtk::Button,
del_bttn: &gtk::Button,
) {
// Create a async channel.
let (sender, receiver) = channel();
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) {
let download_fold = dbqueries::get_podcast_from_id(ep.podcast_id())
.ok()
.map(|pd| get_download_folder(&pd.title().to_owned()).ok())
.and_then(|x| x);
// Pass the desired arguments into the Local Thread Storage.
GLOBAL.with(clone!(download_bttn, play_bttn, del_bttn => move |global| {
*global.borrow_mut() = Some((download_bttn, play_bttn, del_bttn, receiver));
}));
// Start a new download.
if let Some(fold) = download_fold {
manager::add(ep.rowid(), &fold, sender.clone());
}
let pd_title = pd_title.to_owned();
let mut ep = ep.clone();
thread::spawn(move || {
let download_fold = downloader::get_download_folder(&pd_title).unwrap();
let e = downloader::get_episode(&mut ep, download_fold.as_str());
if let Err(err) = e {
error!("Error while trying to download: {:?}", ep.uri());
error!("Error: {}", err);
};
sender.send(true).expect("Couldn't send data to channel");;
glib::idle_add(receive);
});
// Update Views
sender.send(Action::RefreshEpisodesView).unwrap();
sender.send(Action::RefreshWidgetIfVis).unwrap();
}
fn on_play_bttn_clicked(episode_id: i32) {
let local_uri = dbqueries::get_episode_local_uri_from_id(episode_id).unwrap();
let local_uri = dbqueries::get_episode_local_uri_from_id(episode_id)
.ok()
.and_then(|x| x);
if let Some(uri) = local_uri {
if Path::new(&uri).exists() {
info!("Opening {}", uri);
let e = open::that(&uri);
if let Err(err) = e {
open::that(&uri).err().map(|err| {
error!("Error while trying to open file: {}", uri);
error!("Error: {}", err);
};
});
}
} else {
error!(
@ -213,39 +287,95 @@ fn on_play_bttn_clicked(episode_id: i32) {
}
}
fn on_delete_bttn_clicked(episode_id: i32) {
let mut ep = dbqueries::get_episode_from_id(episode_id).unwrap();
// Setup a callback that will update the progress bar.
#[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))]
fn update_progressbar_callback(
prog: Arc<Mutex<manager::Progress>>,
episode_rowid: i32,
progress_bar: gtk::ProgressBar,
local_size: gtk::Label,
) {
timeout_add(
400,
clone!(prog, progress_bar => move || {
let (fraction, downloaded) = {
let m = prog.lock().unwrap();
(m.get_fraction(), m.get_downloaded())
};
let e = delete_local_content(&mut ep);
if let Err(err) = e {
error!("Error while trying to delete file: {:?}", ep.local_uri());
error!("Error: {}", err);
};
}
// Update local_size label
downloaded.file_size(SIZE_OPTS.clone()).ok().map(|x| local_size.set_text(&x));
fn receive() -> glib::Continue {
GLOBAL.with(|global| {
if let Some((ref download_bttn, ref play_bttn, ref del_bttn, ref reciever)) =
*global.borrow()
{
if reciever.try_recv().is_ok() {
download_bttn.hide();
play_bttn.show();
del_bttn.show();
// I hate floating points.
// Update the progress_bar.
if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) {
progress_bar.set_fraction(fraction);
}
}
});
glib::Continue(false)
// info!("Fraction: {}", progress_bar.get_fraction());
// info!("Fraction: {}", fraction);
// Check if the download is still active
let active = {
let m = manager::ACTIVE_DOWNLOADS.read().unwrap();
m.contains_key(&episode_rowid)
};
if (fraction >= 1.0) && (!fraction.is_nan()){
glib::Continue(false)
} else if !active {
glib::Continue(false)
} else {
glib::Continue(true)
}
}),
);
}
pub fn episodes_listbox(pd: &Podcast) -> Result<gtk::ListBox> {
let episodes = dbqueries::get_pd_episodes(pd)?;
// Setup a callback that will update the total_size label
// with the http ContentLength header number rather than
// relying to the RSS feed.
fn update_total_size_callback(prog: Arc<Mutex<manager::Progress>>, total_size: gtk::Label) {
timeout_add(
500,
clone!(prog, total_size => move || {
let total_bytes = {
let m = prog.lock().unwrap();
m.get_total_size()
};
debug!("Total Size: {}", total_bytes);
if total_bytes != 0 {
// Update the total_size label
total_bytes.file_size(SIZE_OPTS.clone()).ok().map(|x| total_size.set_text(&x));
glib::Continue(false)
} else {
glib::Continue(true)
}
}),
);
}
// fn on_delete_bttn_clicked(episode_id: i32) {
// let mut ep = dbqueries::get_episode_from_rowid(episode_id)
// .unwrap()
// .into();
// let e = delete_local_content(&mut ep);
// if let Err(err) = e {
// error!("Error while trying to delete file: {:?}", ep.local_uri());
// error!("Error: {}", err);
// };
// }
pub fn episodes_listbox(pd: &Podcast, sender: Sender<Action>) -> Result<gtk::ListBox> {
let mut episodes = dbqueries::get_pd_episodeswidgets(pd)?;
let list = gtk::ListBox::new();
episodes.into_iter().for_each(|mut ep| {
// let w = epidose_widget(&mut ep, pd.title());
let widget = EpisodeWidget::new_initialized(&mut ep, pd);
list.add(&widget.container)
episodes.iter_mut().for_each(|ep| {
let widget = EpisodeWidget::new(ep, sender.clone());
list.add(&widget.container);
});
list.set_vexpand(false);

View File

@ -1,2 +1,2 @@
pub mod podcast;
pub mod show;
pub mod episode;

View File

@ -1,122 +0,0 @@
use gtk::prelude::*;
use gtk;
use diesel::Identifiable;
use std::fs;
use hammond_data::dbqueries;
use hammond_data::Podcast;
use hammond_downloader::downloader;
use widgets::episode::episodes_listbox;
use utils::get_pixbuf_from_path;
use content;
#[derive(Debug)]
pub struct PodcastWidget {
pub container: gtk::Box,
cover: gtk::Image,
title: gtk::Label,
description: gtk::TextView,
view: gtk::Viewport,
unsub: gtk::Button,
played: gtk::Button,
}
impl PodcastWidget {
pub fn new() -> PodcastWidget {
// Adapted from gnome-music AlbumWidget
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/podcast_widget.ui");
let container: gtk::Box = builder.get_object("podcast_widget").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap();
let title: gtk::Label = builder.get_object("title_label").unwrap();
let description: gtk::TextView = builder.get_object("desc_text_view").unwrap();
let view: gtk::Viewport = builder.get_object("view").unwrap();
let unsub: gtk::Button = builder.get_object("unsub_button").unwrap();
let played: gtk::Button = builder.get_object("mark_all_played_button").unwrap();
PodcastWidget {
container,
cover,
title,
description,
view,
unsub,
played,
}
}
pub fn new_initialized(stack: &gtk::Stack, pd: &Podcast) -> PodcastWidget {
let pdw = PodcastWidget::new();
pdw.init(stack, pd);
pdw
}
pub fn init(&self, stack: &gtk::Stack, pd: &Podcast) {
WidgetExt::set_name(&self.container, &pd.id().to_string());
// TODO: should spawn a thread to avoid locking the UI probably.
self.unsub.connect_clicked(clone!(stack, pd => move |bttn| {
on_unsub_button_clicked(&stack, &pd, bttn);
}));
self.title.set_text(pd.title());
let listbox = episodes_listbox(pd);
if let Ok(l) = listbox {
self.view.add(&l);
}
{
let buff = self.description.get_buffer().unwrap();
buff.set_text(pd.description());
}
let img = get_pixbuf_from_path(pd);
if let Some(i) = img {
self.cover.set_from_pixbuf(&i);
}
self.played.connect_clicked(clone!(stack, pd => move |_| {
on_played_button_clicked(&stack, &pd);
}));
self.show_played_button(pd);
}
fn show_played_button(&self, pd: &Podcast) {
let new_episodes = dbqueries::get_pd_unplayed_episodes(pd);
if let Ok(n) = new_episodes {
if !n.is_empty() {
self.played.show()
}
}
}
}
fn on_unsub_button_clicked(stack: &gtk::Stack, pd: &Podcast, unsub_button: &gtk::Button) {
let res = dbqueries::remove_feed(pd);
if res.is_ok() {
info!("{} was removed succesfully.", pd.title());
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.hide();
let dl_fold = downloader::get_download_folder(pd.title());
if let Ok(fold) = dl_fold {
let res3 = fs::remove_dir_all(&fold);
if res3.is_ok() {
info!("All the content at, {} was removed succesfully", &fold);
}
};
}
content::update_podcasts(stack);
content::show_podcasts(stack);
}
fn on_played_button_clicked(stack: &gtk::Stack, pd: &Podcast) {
let _ = dbqueries::update_none_to_played_now(pd);
content::update_widget_preserve_vis(stack, pd);
}

View File

@ -0,0 +1,133 @@
use dissolve;
use gtk;
use gtk::prelude::*;
use open;
use hammond_data::Podcast;
use hammond_data::dbqueries;
use hammond_data::utils::{delete_show, replace_extra_spaces};
use app::Action;
use utils::get_pixbuf_from_path;
use widgets::episode::episodes_listbox;
use std::sync::mpsc::Sender;
use std::thread;
#[derive(Debug, Clone)]
pub struct ShowWidget {
pub container: gtk::Box,
scrolled_window: gtk::ScrolledWindow,
cover: gtk::Image,
description: gtk::Label,
link: gtk::Button,
settings: gtk::MenuButton,
unsub: gtk::Button,
episodes: gtk::Frame,
}
impl Default for ShowWidget {
fn default() -> Self {
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/show_widget.ui");
let container: gtk::Box = builder.get_object("container").unwrap();
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
let episodes: gtk::Frame = builder.get_object("episodes").unwrap();
let cover: gtk::Image = builder.get_object("cover").unwrap();
let description: gtk::Label = builder.get_object("description").unwrap();
let unsub: gtk::Button = builder.get_object("unsub_button").unwrap();
let link: gtk::Button = builder.get_object("link_button").unwrap();
let settings: gtk::MenuButton = builder.get_object("settings_button").unwrap();
ShowWidget {
container,
scrolled_window,
cover,
description,
unsub,
link,
settings,
episodes,
}
}
}
impl ShowWidget {
pub fn new(pd: &Podcast, sender: Sender<Action>) -> ShowWidget {
let pdw = ShowWidget::default();
pdw.init(pd, sender);
pdw
}
pub fn init(&self, pd: &Podcast, sender: Sender<Action>) {
// Hacky workaround so the pd.id() can be retrieved from the `ShowStack`.
WidgetExt::set_name(&self.container, &pd.id().to_string());
self.unsub
.connect_clicked(clone!(pd, sender => move |bttn| {
on_unsub_button_clicked(&pd, bttn, sender.clone());
}));
self.setup_listbox(pd, sender.clone());
self.set_cover(pd);
self.set_description(pd.description());
let link = pd.link().to_owned();
self.link.set_tooltip_text(Some(link.as_str()));
self.link.connect_clicked(move |_| {
info!("Opening link: {}", &link);
open::that(&link)
.err()
.map(|err| error!("Something went wrong: {}", err));
});
}
/// Populate the listbox with the shows episodes.
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
let listbox = episodes_listbox(pd, sender.clone());
listbox.ok().map(|l| self.episodes.add(&l));
}
/// Set the show cover.
fn set_cover(&self, pd: &Podcast) {
let img = get_pixbuf_from_path(&pd.clone().into(), 128);
img.map(|i| self.cover.set_from_pixbuf(&i));
}
/// Set the descripton text.
fn set_description(&self, text: &str) {
// TODO: Temporary solution until we render html urls/bold/italic probably with markup.
let desc = dissolve::strip_html_tags(text).join(" ");
self.description.set_text(&replace_extra_spaces(&desc));
}
/// Set scrolled window vertical adjustment.
pub fn set_vadjustment(&self, vadjustment: &gtk::Adjustment) {
self.scrolled_window.set_vadjustment(vadjustment)
}
}
fn on_unsub_button_clicked(pd: &Podcast, unsub_button: &gtk::Button, sender: Sender<Action>) {
// hack to get away without properly checking for none.
// if pressed twice would panic.
unsub_button.hide();
// Spawn a thread so it won't block the ui.
thread::spawn(clone!(pd => move || {
if let Err(err) = delete_show(&pd) {
error!("Something went wrong trying to remove {}", pd.title());
error!("Error: {}", err);
}
}));
sender.send(Action::HeaderBarNormal).unwrap();
sender.send(Action::ShowShowsAnimated).unwrap();
// Queue a refresh after the switch to avoid blocking the db.
sender.send(Action::RefreshShowsView).unwrap();
sender.send(Action::RefreshEpisodesView).unwrap();
}
#[allow(dead_code)]
fn on_played_button_clicked(pd: &Podcast, sender: Sender<Action>) {
let _ = dbqueries::update_none_to_played_now(pd);
sender.send(Action::RefreshWidget).unwrap();
}

View File

@ -26,6 +26,7 @@ cargo_script = find_program('scripts/cargo.sh')
cargo_release = custom_target('cargo-build',
build_by_default: true,
build_always: true,
output: ['hammond'],
install: true,
install_dir: hammond_bindir,

View File

@ -9,9 +9,10 @@
"command": "hammond",
"finish-args": [
"--share=network",
"--share=ipc",
"--socket=x11",
"--socket=wayland",
"--talk-name=org.freedesktop.Notifications"
"--talk-name=org.freedesktop.Desktop"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin",
@ -35,4 +36,4 @@
]
}
]
}
}

View File

@ -1,26 +1,15 @@
unstable_features = true
verbose = false
disable_all_formatting = false
skip_children = false
max_width = 100
comment_width = 100
wrap_comments = true
error_on_line_overflow = true
error_on_line_overflow_comments = false
tab_spaces = 4
newline_style = "Unix"
fn_call_style = "Block"
report_todo = "Never"
report_fixme = "Never"
reorder_extern_crates = true
reorder_extern_crates_in_group = true
reorder_imports = false
hard_tabs = false
spaces_within_parens = false
newline_style = "Unix"
write_mode = "Overwrite"
merge_derives = true
condense_wildcard_suffixes = false
format_strings = true
multiline_closure_forces_block = true
attributes_on_same_line_as_field = true
attributes_on_same_line_as_variant = true
normalize_comments = true
reorder_imports = true
reorder_imported_names = true
reorder_imports_in_group = true

View File

@ -1,3 +1,3 @@
#!/bin/sh
cargo build --release && cp $1/target/release/hammond-gtk $2
cargo build --release -p hammond-gtk && cp $1/target/release/hammond-gtk $2

View File

@ -35,5 +35,5 @@ cp -rf vendor $DIST/
# packaging
cd $DEST/dist
tar -czvf $VERSION.tar.gz $VERSION
tar -cJvf $VERSION.tar.xz $VERSION