Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85983ec490 | ||
|
|
7abf6bcade | ||
|
|
854581f0bf | ||
|
|
c79a92f3b2 | ||
|
|
4d6c3a67b1 | ||
|
|
83abb5a825 | ||
|
|
d618771125 | ||
|
|
45c9fd308d | ||
|
|
0c00ee1320 | ||
|
|
d332636dd4 | ||
|
|
32654f6cb2 | ||
|
|
14a90e7138 | ||
|
|
dd2366a15e | ||
|
|
370ba2d461 | ||
|
|
ef655ef5e0 | ||
|
|
5cc550c830 | ||
|
|
52cbe67756 | ||
|
|
c910e0af40 | ||
|
|
7a74534285 | ||
|
|
916775462d | ||
|
|
3d98600126 | ||
|
|
7ba834ee8d | ||
|
|
1c527cba03 | ||
|
|
2d7ba7b246 | ||
|
|
4ed70a8011 | ||
|
|
7a3a148359 | ||
|
|
e07e35110d | ||
|
|
a463753c84 | ||
|
|
a946ddfab1 | ||
|
|
af1cb43bd6 | ||
|
|
935d61324f | ||
|
|
10e016f2ea | ||
|
|
154655d571 | ||
|
|
491cd8f01c | ||
|
|
2b711ff04c | ||
|
|
07eadd2364 | ||
|
|
7086afe73d | ||
|
|
f21398357b | ||
|
|
c338802329 | ||
|
|
c3076e748e | ||
|
|
1595256c86 | ||
|
|
0623592f75 | ||
|
|
f2444f151c | ||
|
|
710a3f2552 | ||
|
|
6071c664e7 | ||
|
|
e203815f4f | ||
|
|
c3658080d3 | ||
|
|
8703470010 | ||
|
|
88cc7e6fec | ||
|
|
badcbc32c6 | ||
|
|
daa8f15ce9 |
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
* Downlaoding and loading images now is done asynchronously and is not blocking programs execution.
|
||||
[#7](https://gitlab.gnome.org/alatiera/Hammond/issues/7)
|
||||
* Bold, italics links and some other `html` tags can now be rendered in the Show Description.
|
||||
[#25](https://gitlab.gnome.org/alatiera/Hammond/issues/25)
|
||||
|
||||
## [0.3.1] - 2018-03-28
|
||||
|
||||
|
||||
Generated
+352
-274
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,7 @@ flatpak build-bundle repo hammond org.gnome.Hammond
|
||||
|
||||
```sh
|
||||
git clone https://gitlab.gnome.org/alatiera/hammond.git
|
||||
cd Hammond/
|
||||
cd hammond/
|
||||
./configure --prefix=/usr/local
|
||||
make && sudo make install
|
||||
```
|
||||
@@ -71,26 +71,29 @@ You can run `sudo make uninstall` for removal
|
||||
|
||||
#### Dependencies
|
||||
|
||||
* Rust stable 1.22 or later.
|
||||
* Rust stable 1.22 or later along with cargo.
|
||||
* Gtk+ 3.22 or later
|
||||
* Meson
|
||||
* A network connection
|
||||
|
||||
Offline build are possible too, but [`cargo-vendor`][vendor] would have to be setup first
|
||||
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```sh
|
||||
apt-get update -yqq
|
||||
apt-get install -yqq --no-install-recommends build-essential
|
||||
apt-get install -yqq --no-install-recommends libgtk-3-dev meson
|
||||
apt-get install -yqq --no-install-recommends rustc cargo libgtk-3-dev meson
|
||||
```
|
||||
|
||||
**Fedora**
|
||||
|
||||
```sh
|
||||
dnf install -y gtk3-devel glib2-devel openssl-devel sqlite-devel meson
|
||||
dnf install -y rust cargo gtk3-devel glib2-devel openssl-devel sqlite-devel meson
|
||||
```
|
||||
|
||||
If you happen to build it on other distributions please let me know the names
|
||||
of the corresponding libraries. Feel free to open a PR or an Issue to note it.
|
||||
of the corresponding libraries. Feel free to open a MR or an Issue to note it.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -139,3 +142,5 @@ Hammond's design is heavily insired by [GNOME Music](https://wiki.gnome.org/Desi
|
||||
We also copied some elements from [GNOME News](https://wiki.gnome.org/Design/Apps/Potential/News).
|
||||
|
||||
And almost the entirety of the build system is copied from the [Fractal](https://gitlab.gnome.org/danigm/fractal) project.
|
||||
|
||||
[vendor]: https://github.com/alexcrichton/cargo-vendor
|
||||
@@ -6,9 +6,9 @@ workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
ammonia = "1.1.0"
|
||||
chrono = "0.4.0"
|
||||
chrono = "0.4.1"
|
||||
derive_builder = "0.5.1"
|
||||
itertools = "0.7.7"
|
||||
itertools = "0.7.8"
|
||||
lazy_static = "1.0.0"
|
||||
log = "0.4.1"
|
||||
rayon = "1.0.1"
|
||||
@@ -16,12 +16,11 @@ rfc822_sanitizer = "0.3.3"
|
||||
rss = "1.4.0"
|
||||
url = "1.7.0"
|
||||
xdg = "2.1.0"
|
||||
futures = "0.1.18"
|
||||
hyper = "0.11.22"
|
||||
tokio-core = "0.1.15"
|
||||
futures = "0.1.21"
|
||||
hyper = "0.11.24"
|
||||
tokio-core = "0.1.16"
|
||||
hyper-tls = "0.1.3"
|
||||
native-tls = "0.1.5"
|
||||
futures-cpupool = "0.1.8"
|
||||
num_cpus = "1.8.0"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
@@ -36,8 +35,9 @@ version = "1.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
rand = "0.4.2"
|
||||
tempdir = "0.3.6"
|
||||
criterion = "0.2.1"
|
||||
tempdir = "0.3.7"
|
||||
criterion = "0.2.2"
|
||||
pretty_assertions = "0.5.1"
|
||||
|
||||
[[bench]]
|
||||
name = "bench"
|
||||
|
||||
@@ -3,7 +3,6 @@ extern crate criterion;
|
||||
use criterion::Criterion;
|
||||
|
||||
// extern crate futures;
|
||||
// extern crate futures_cpupool;
|
||||
extern crate hammond_data;
|
||||
extern crate hyper;
|
||||
extern crate hyper_tls;
|
||||
@@ -15,7 +14,6 @@ extern crate rss;
|
||||
// use rayon::prelude::*;
|
||||
|
||||
// use futures::future::*;
|
||||
// use futures_cpupool::CpuPool;
|
||||
use tokio_core::reactor::Core;
|
||||
|
||||
use hammond_data::FeedBuilder;
|
||||
|
||||
@@ -175,13 +175,23 @@ pub fn get_pd_episodes(parent: &Podcast) -> Result<Vec<Episode>, DataError> {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||
pub fn get_pd_episodes_count(parent: &Podcast) -> Result<i64, DataError> {
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
Episode::belonging_to(parent)
|
||||
.count()
|
||||
.get_result(&con)
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn get_pd_episodeswidgets(_parent: &Podcast) -> Result<Vec<EpisodeWidgetQuery>, DataError> {
|
||||
use schema::episode::dsl::*;
|
||||
let db = connection();
|
||||
let con = db.get()?;
|
||||
|
||||
episode.select((rowid, title, uri, local_uri, epoch, length, duration, played, podcast_id))
|
||||
.filter(podcast_id.eq(parent.id()))
|
||||
// .filter(podcast_id.eq(parent.id()))
|
||||
// .group_by(epoch)
|
||||
.order(epoch.desc())
|
||||
.load::<EpisodeWidgetQuery>(&con)
|
||||
|
||||
@@ -3,16 +3,10 @@ use diesel::r2d2;
|
||||
use diesel_migrations::RunMigrationsError;
|
||||
use hyper;
|
||||
use native_tls;
|
||||
// use rss;
|
||||
use rss;
|
||||
use url;
|
||||
|
||||
use std::io;
|
||||
// use std::fmt;
|
||||
|
||||
// fadsadfs NOT SYNC
|
||||
// #[derive(Fail, Debug)]
|
||||
// #[fail(display = "RSS Error: {}", _0)]
|
||||
// struct RSSError(rss::Error);
|
||||
|
||||
#[derive(Fail, Debug)]
|
||||
pub enum DataError {
|
||||
@@ -34,8 +28,7 @@ pub enum DataError {
|
||||
#[fail(display = "IO Error: {}", _0)]
|
||||
IOError(#[cause] io::Error),
|
||||
#[fail(display = "RSS Error: {}", _0)]
|
||||
// Rss::Error is not yet Sync
|
||||
RssCrateError(String),
|
||||
RssError(#[cause] rss::Error),
|
||||
#[fail(display = "Error: {}", _0)]
|
||||
Bail(String),
|
||||
#[fail(display = "Request to {} returned {}. Context: {}", url, status_code, context)]
|
||||
@@ -100,6 +93,12 @@ impl From<io::Error> for DataError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rss::Error> for DataError {
|
||||
fn from(err: rss::Error) -> Self {
|
||||
DataError::RssError(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DataError {
|
||||
fn from(err: String) -> Self {
|
||||
DataError::Bail(err)
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
//! FIXME: Docs
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
#[macro_use]
|
||||
extern crate derive_builder;
|
||||
#[macro_use]
|
||||
@@ -38,7 +42,6 @@ extern crate log;
|
||||
extern crate ammonia;
|
||||
extern crate chrono;
|
||||
extern crate futures;
|
||||
extern crate futures_cpupool;
|
||||
extern crate hyper;
|
||||
extern crate hyper_tls;
|
||||
extern crate itertools;
|
||||
|
||||
@@ -10,7 +10,7 @@ use errors::DataError;
|
||||
use models::{Episode, EpisodeMinimal, Index, Insert, Update};
|
||||
use parser;
|
||||
use schema::episode;
|
||||
use utils::{replace_extra_spaces, url_cleaner};
|
||||
use utils::url_cleaner;
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[table_name = "episode"]
|
||||
@@ -196,11 +196,14 @@ impl NewEpisodeMinimal {
|
||||
let title = item.title().unwrap().trim().to_owned();
|
||||
let guid = item.guid().map(|s| s.value().trim().to_owned());
|
||||
|
||||
let uri = if let Some(url) = item.enclosure().map(|s| url_cleaner(s.url())) {
|
||||
Some(url)
|
||||
} else if item.link().is_some() {
|
||||
item.link().map(|s| url_cleaner(s))
|
||||
} else {
|
||||
let uri = item.enclosure()
|
||||
.map(|s| url_cleaner(s.url()))
|
||||
// Fallback to Rss.Item.link if enclosure is None.
|
||||
.or_else(|| item.link().map(|s| url_cleaner(s)));
|
||||
|
||||
// If url is still None return an Error as this behaviour is
|
||||
// compliant with the RSS Spec.
|
||||
if uri.is_none() {
|
||||
let err = DataError::ParseEpisodeError {
|
||||
reason: "No url specified for the item.".into(),
|
||||
parent_id,
|
||||
@@ -212,7 +215,7 @@ impl NewEpisodeMinimal {
|
||||
// Default to rfc2822 represantation of epoch 0.
|
||||
let date = parse_rfc822(item.pub_date().unwrap_or("Thu, 1 Jan 1970 00:00:00 +0000"));
|
||||
// Should treat information from the rss feeds as invalid by default.
|
||||
// Case: Thu, 05 Aug 2016 06:00:00 -0400 <-- Actually that was friday.
|
||||
// Case: "Thu, 05 Aug 2016 06:00:00 -0400" <-- Actually that was friday.
|
||||
let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0);
|
||||
|
||||
let duration = parser::parse_itunes_duration(item.itunes_ext());
|
||||
@@ -228,18 +231,10 @@ impl NewEpisodeMinimal {
|
||||
.map_err(From::from)
|
||||
}
|
||||
|
||||
// TODO: TryInto is stabilizing in rustc v1.26!
|
||||
pub(crate) fn into_new_episode(self, item: &rss::Item) -> NewEpisode {
|
||||
let length = || -> Option<i32> { item.enclosure().map(|x| x.length().parse().ok())? }();
|
||||
|
||||
// Prefer itunes summary over rss.description since many feeds put html into
|
||||
// rss.description.
|
||||
let summary = item.itunes_ext().map(|s| s.summary()).and_then(|s| s);
|
||||
let description = if summary.is_some() {
|
||||
summary.map(|s| replace_extra_spaces(&ammonia::clean(s)))
|
||||
} else {
|
||||
item.description()
|
||||
.map(|s| replace_extra_spaces(&ammonia::clean(s)))
|
||||
};
|
||||
let length = item.enclosure().and_then(|x| x.length().parse().ok());
|
||||
let description = item.description().map(|s| ammonia::clean(s));
|
||||
|
||||
NewEpisodeBuilder::default()
|
||||
.title(self.title)
|
||||
@@ -431,12 +426,12 @@ mod tests {
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_LUP_2: NewEpisode = {
|
||||
let descr = "The Gnome project is about to solve one of our audience's biggest \
|
||||
Wayland’s concerns. But as the project takes on a new level of \
|
||||
relevance, decisions for the next version of Gnome have us worried about \
|
||||
the future.\nPlus we chat with Wimpy about the Ubuntu Rally in NYC, \
|
||||
Microsoft’s sneaky move to turn Windows 10 into the “ULTIMATE LINUX \
|
||||
RUNTIME”, community news & more!";
|
||||
let descr =
|
||||
"<p>The Gnome project is about to solve one of our audience's biggest Wayland’s \
|
||||
concerns. But as the project takes on a new level of relevance, decisions for \
|
||||
the next version of Gnome have us worried about the future.</p>\n\n<p>Plus we \
|
||||
chat with Wimpy about the Ubuntu Rally in NYC, Microsoft’s sneaky move to turn \
|
||||
Windows 10 into the “ULTIMATE LINUX RUNTIME”, community news & more!</p>";
|
||||
|
||||
NewEpisodeBuilder::default()
|
||||
.title("Gnome Does it Again | LUP 213")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use ammonia;
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use ammonia;
|
||||
use rss;
|
||||
|
||||
use errors::DataError;
|
||||
@@ -11,7 +10,7 @@ use schema::podcast;
|
||||
|
||||
use database::connection;
|
||||
use dbqueries;
|
||||
use utils::{replace_extra_spaces, url_cleaner};
|
||||
use utils::url_cleaner;
|
||||
|
||||
#[derive(Insertable, AsChangeset)]
|
||||
#[table_name = "podcast"]
|
||||
@@ -90,24 +89,15 @@ impl NewPodcast {
|
||||
pub(crate) fn new(chan: &rss::Channel, source_id: i32) -> NewPodcast {
|
||||
let title = chan.title().trim();
|
||||
|
||||
// Prefer itunes summary over rss.description since many feeds put html into
|
||||
// rss.description.
|
||||
let summary = chan.itunes_ext().map(|s| s.summary()).and_then(|s| s);
|
||||
let description = if let Some(sum) = summary {
|
||||
replace_extra_spaces(&ammonia::clean(sum))
|
||||
} else {
|
||||
replace_extra_spaces(&ammonia::clean(chan.description()))
|
||||
};
|
||||
|
||||
let description = ammonia::clean(chan.description().trim());
|
||||
let link = url_cleaner(chan.link());
|
||||
|
||||
// Try to get the itunes img first
|
||||
let itunes_img = chan.itunes_ext()
|
||||
.and_then(|s| s.image())
|
||||
.map(|s| s.to_owned());
|
||||
let image_uri = if itunes_img.is_some() {
|
||||
itunes_img
|
||||
} else {
|
||||
chan.image().map(|s| s.url().to_owned())
|
||||
};
|
||||
// If itunes is None, try to get the channel.image from the rss spec
|
||||
let image_uri = itunes_img.or_else(|| chan.image().map(|s| s.url().to_owned()));
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
.title(title)
|
||||
@@ -169,7 +159,7 @@ mod tests {
|
||||
let descr = "The people behind The Intercept’s fearless reporting and incisive \
|
||||
commentary—Jeremy Scahill, Glenn Greenwald, Betsy Reed and \
|
||||
others—discuss the crucial issues of our time: national security, civil \
|
||||
liberties, foreign policy, and criminal justice. Plus interviews with \
|
||||
liberties, foreign policy, and criminal justice. Plus interviews with \
|
||||
artists, thinkers, and newsmakers who challenge our preconceptions about \
|
||||
the world we live in.";
|
||||
|
||||
@@ -203,15 +193,15 @@ mod tests {
|
||||
.unwrap()
|
||||
};
|
||||
static ref EXPECTED_TIPOFF: NewPodcast = {
|
||||
let desc = "Welcome to The Tip Off- the podcast where we take you behind the scenes \
|
||||
of some of the best investigative journalism from recent years. Each \
|
||||
episode we’ll be digging into an investigative scoop- hearing from the \
|
||||
journalists behind the work as they tell us about the leads, the \
|
||||
let desc = "<p>Welcome to The Tip Off- the podcast where we take you behind the \
|
||||
scenes of some of the best investigative journalism from recent years. \
|
||||
Each episode we’ll be digging into an investigative scoop- hearing from \
|
||||
the journalists behind the work as they tell us about the leads, the \
|
||||
dead-ends and of course, the tip offs. There’ll be car chases, slammed \
|
||||
doors, terrorist cells, meetings in dimly lit bars and cafes, wrangling \
|
||||
with despotic regimes and much more. So if you’re curious about the fun, \
|
||||
complicated detective work that goes into doing great investigative \
|
||||
journalism- then this is the podcast for you.";
|
||||
journalism- then this is the podcast for you.</p>";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
.title("The Tip Off")
|
||||
@@ -231,9 +221,9 @@ mod tests {
|
||||
love, a crashed UFO, an alien body, and an impossible heist unlike any \
|
||||
ever attempted - scripted by Mac Rogers, the award-winning playwright \
|
||||
and writer of the multi-million download The Message and LifeAfter.</p>";
|
||||
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
|
||||
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
|
||||
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
|
||||
let img = "https://dfkfj8j276wwv.cloudfront.net/images/2c/5f/a0/1a/2c5fa01a-ae78-4a8c-\
|
||||
b183-7311d2e436c3/b3a4aa57a576bb662191f2a6bc2a436c8c4ae256ecffaff5c4c54fd42e\
|
||||
923914941c264d01efb1833234b52c9530e67d28a8cebbe3d11a4bc0fbbdf13ecdf1c3.jpeg";
|
||||
|
||||
NewPodcastBuilder::default()
|
||||
.title("Steal the Stars")
|
||||
|
||||
@@ -11,7 +11,6 @@ use hyper_tls::HttpsConnector;
|
||||
|
||||
// use futures::future::ok;
|
||||
use futures::prelude::*;
|
||||
use futures_cpupool::CpuPool;
|
||||
|
||||
use database::connection;
|
||||
use errors::DataError;
|
||||
@@ -102,6 +101,14 @@ impl Source {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_err(self, context: &str, code: StatusCode) -> DataError {
|
||||
DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: context.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO match on more stuff
|
||||
// 301: Moved Permanently
|
||||
// 304: Up to date Feed, checked with the Etag
|
||||
@@ -115,75 +122,21 @@ impl Source {
|
||||
fn match_status(mut self, res: Response) -> Result<(Self, Response), DataError> {
|
||||
self.update_etag(&res)?;
|
||||
let code = res.status();
|
||||
match code {
|
||||
StatusCode::NotModified => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "304: skipping..".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
match code {
|
||||
StatusCode::NotModified => return Err(self.make_err("304: skipping..", code)),
|
||||
StatusCode::MovedPermanently => {
|
||||
error!("Feed was moved permanently.");
|
||||
self.handle_301(&res)?;
|
||||
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "301: Feed was moved permanently.".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
return Err(self.make_err("301: Feed was moved permanently.", code));
|
||||
}
|
||||
StatusCode::TemporaryRedirect => debug!("307: Temporary Redirect."),
|
||||
StatusCode::PermanentRedirect => warn!("308: Permanent Redirect."),
|
||||
StatusCode::Unauthorized => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "401: Unauthorized.".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
StatusCode::Forbidden => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "403: Forbidden.".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
StatusCode::NotFound => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "404: Not found.".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
StatusCode::RequestTimeout => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "408: Request Timeout.".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
StatusCode::Gone => {
|
||||
let err = DataError::HttpStatusError {
|
||||
url: self.uri,
|
||||
status_code: code,
|
||||
context: "410: Feed was deleted..".into(),
|
||||
};
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
StatusCode::Unauthorized => return Err(self.make_err("401: Unauthorized.", code)),
|
||||
StatusCode::Forbidden => return Err(self.make_err("403: Forbidden.", code)),
|
||||
StatusCode::NotFound => return Err(self.make_err("404: Not found.", code)),
|
||||
StatusCode::RequestTimeout => return Err(self.make_err("408: Request Timeout.", code)),
|
||||
StatusCode::Gone => return Err(self.make_err("410: Feed was deleted..", code)),
|
||||
_ => info!("HTTP StatusCode: {}", code),
|
||||
};
|
||||
Ok((self, res))
|
||||
@@ -225,12 +178,11 @@ impl Source {
|
||||
pub fn into_feed(
|
||||
self,
|
||||
client: &Client<HttpsConnector<HttpConnector>>,
|
||||
pool: CpuPool,
|
||||
ignore_etags: bool,
|
||||
) -> Box<Future<Item = Feed, Error = DataError>> {
|
||||
let id = self.id();
|
||||
let feed = self.request_constructor(client, ignore_etags)
|
||||
.and_then(move |(_, res)| response_to_channel(res, pool))
|
||||
.and_then(move |(_, res)| response_to_channel(res))
|
||||
.and_then(move |chan| {
|
||||
FeedBuilder::default()
|
||||
.channel(chan)
|
||||
@@ -283,21 +235,16 @@ impl Source {
|
||||
}
|
||||
|
||||
#[allow(needless_pass_by_value)]
|
||||
fn response_to_channel(
|
||||
res: Response,
|
||||
pool: CpuPool,
|
||||
) -> Box<Future<Item = Channel, Error = DataError> + Send> {
|
||||
fn response_to_channel(res: Response) -> Box<Future<Item = Channel, Error = DataError> + Send> {
|
||||
let chan = res.body()
|
||||
.concat2()
|
||||
.map(|x| x.into_iter())
|
||||
.map_err(From::from)
|
||||
.map(|iter| iter.collect::<Vec<u8>>())
|
||||
.map(|utf_8_bytes| String::from_utf8_lossy(&utf_8_bytes).into_owned())
|
||||
.and_then(|buf| {
|
||||
Channel::from_str(&buf).or_else(|err| Err(DataError::RssCrateError(format!("{}", err))))
|
||||
});
|
||||
let cpu_chan = pool.spawn(chan);
|
||||
Box::new(cpu_chan)
|
||||
.and_then(|buf| Channel::from_str(&buf).map_err(From::from));
|
||||
|
||||
Box::new(chan)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -312,7 +259,6 @@ mod tests {
|
||||
fn test_into_feed() {
|
||||
truncate_db().unwrap();
|
||||
|
||||
let pool = CpuPool::new_num_cpus();
|
||||
let mut core = Core::new().unwrap();
|
||||
let client = Client::configure()
|
||||
.connector(HttpsConnector::new(4, &core.handle()).unwrap())
|
||||
@@ -323,7 +269,7 @@ mod tests {
|
||||
let source = Source::from_url(url).unwrap();
|
||||
let id = source.id();
|
||||
|
||||
let feed = source.into_feed(&client, pool.clone(), true);
|
||||
let feed = source.into_feed(&client, true);
|
||||
let feed = core.run(feed).unwrap();
|
||||
|
||||
let expected = get_feed("tests/feeds/2018-01-20-Intercepted.xml", id);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! Docs.
|
||||
|
||||
use futures::future::*;
|
||||
use futures_cpupool::CpuPool;
|
||||
// use futures::prelude::*;
|
||||
|
||||
use hyper::Client;
|
||||
@@ -20,23 +19,6 @@ use models::{IndexState, NewEpisode, NewEpisodeMinimal};
|
||||
|
||||
// use std::sync::{Arc, Mutex};
|
||||
|
||||
macro_rules! clone {
|
||||
(@param _) => ( _ );
|
||||
(@param $x:ident) => ( $x );
|
||||
($($n:ident),+ => move || $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move || $body
|
||||
}
|
||||
);
|
||||
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move |$(clone!(@param $p),)+| $body
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The pipline to be run for indexing and updating a Podcast feed that originates from
|
||||
/// `Source.uri`.
|
||||
///
|
||||
@@ -47,13 +29,12 @@ pub fn pipeline<S: IntoIterator<Item = Source>>(
|
||||
sources: S,
|
||||
ignore_etags: bool,
|
||||
tokio_core: &mut Core,
|
||||
pool: &CpuPool,
|
||||
client: Client<HttpsConnector<HttpConnector>>,
|
||||
) -> Result<(), DataError> {
|
||||
let list: Vec<_> = sources
|
||||
.into_iter()
|
||||
.map(clone!(pool => move |s| s.into_feed(&client, pool.clone(), ignore_etags)))
|
||||
.map(|fut| fut.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index()))))
|
||||
.map(move |s| s.into_feed(&client, ignore_etags))
|
||||
.map(|fut| fut.and_then(|feed| feed.index()))
|
||||
.map(|fut| fut.map(|_| ()).map_err(|err| error!("Error: {}", err)))
|
||||
.collect();
|
||||
|
||||
@@ -67,26 +48,24 @@ pub fn pipeline<S: IntoIterator<Item = Source>>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a tokio `reactor::Core`, a `CpuPool`, and a `hyper::Client` and
|
||||
/// Creates a tokio `reactor::Core`, and a `hyper::Client` and
|
||||
/// runs the pipeline.
|
||||
pub fn run(sources: Vec<Source>, ignore_etags: bool) -> Result<(), DataError> {
|
||||
if sources.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pool = CpuPool::new_num_cpus();
|
||||
let mut core = Core::new()?;
|
||||
let handle = core.handle();
|
||||
let client = Client::configure()
|
||||
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
|
||||
.build(&handle);
|
||||
|
||||
pipeline(sources, ignore_etags, &mut core, &pool, client)
|
||||
pipeline(sources, ignore_etags, &mut core, client)
|
||||
}
|
||||
|
||||
/// Docs
|
||||
pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataError> {
|
||||
let pool = CpuPool::new_num_cpus();
|
||||
let mut core = Core::new()?;
|
||||
let handle = core.handle();
|
||||
|
||||
@@ -94,8 +73,8 @@ pub fn index_single_source(s: Source, ignore_etags: bool) -> Result<(), DataErro
|
||||
.connector(HttpsConnector::new(num_cpus::get(), &handle)?)
|
||||
.build(&handle);
|
||||
|
||||
let work = s.into_feed(&client, pool.clone(), ignore_etags)
|
||||
.and_then(clone!(pool => move |feed| pool.clone().spawn(feed.index())))
|
||||
let work = s.into_feed(&client, ignore_etags)
|
||||
.and_then(move |feed| feed.index())
|
||||
.map(|_| ());
|
||||
|
||||
core.run(work)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use chrono::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use itertools::Itertools;
|
||||
use url::{Position, Url};
|
||||
|
||||
use dbqueries;
|
||||
@@ -110,22 +109,6 @@ pub fn url_cleaner(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper functions that strips extra spaces and newlines and ignores the tabs.
|
||||
#[allow(match_same_arms)]
|
||||
pub fn replace_extra_spaces(s: &str) -> String {
|
||||
s.trim()
|
||||
.chars()
|
||||
.filter(|ch| *ch != '\t')
|
||||
.coalesce(|current, next| match (current, next) {
|
||||
('\n', '\n') => Ok('\n'),
|
||||
('\n', ' ') => Ok('\n'),
|
||||
(' ', '\n') => Ok('\n'),
|
||||
(' ', ' ') => Ok(' '),
|
||||
(_, _) => Err((current, next)),
|
||||
})
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Returns the URI of a Podcast Downloads given it's title.
|
||||
pub fn get_download_folder(pd_title: &str) -> Result<String, DataError> {
|
||||
// It might be better to make it a hash of the title or the podcast rowid
|
||||
@@ -296,24 +279,6 @@ mod tests {
|
||||
assert_eq!(url_cleaner(&format!(" {}\t\n", bad_url)), good_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace() {
|
||||
let bad_txt = "1 2 3 4 5";
|
||||
let valid_txt = "1 2 3 4 5";
|
||||
|
||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
||||
|
||||
let bad_txt = "1 2 3 \n 4 5\n";
|
||||
let valid_txt = "1 2 3\n4 5";
|
||||
|
||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
||||
|
||||
let bad_txt = "1 2 3 \n\n\n \n 4 5\n";
|
||||
let valid_txt = "1 2 3\n4 5";
|
||||
|
||||
assert_eq!(replace_extra_spaces(&bad_txt), valid_txt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
// This test needs access to local system so we ignore it by default.
|
||||
#[ignore]
|
||||
|
||||
@@ -6,11 +6,11 @@ workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
error-chain = "0.11.0"
|
||||
hyper = "0.11.22"
|
||||
hyper = "0.11.24"
|
||||
log = "0.4.1"
|
||||
mime_guess = "1.8.4"
|
||||
reqwest = "0.8.5"
|
||||
tempdir = "0.3.6"
|
||||
tempdir = "0.3.7"
|
||||
glob = "0.2.11"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
@@ -18,3 +18,5 @@ failure_derive = "0.1.1"
|
||||
[dependencies.hammond-data]
|
||||
path = "../hammond-data"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "0.5.1"
|
||||
|
||||
@@ -9,6 +9,10 @@ extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
extern crate glob;
|
||||
extern crate hammond_data;
|
||||
extern crate hyper;
|
||||
|
||||
@@ -6,8 +6,8 @@ version = "0.1.0"
|
||||
workspace = "../"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.0"
|
||||
dissolve = "0.2.2"
|
||||
chrono = "0.4.1"
|
||||
crossbeam-channel = "0.1.2"
|
||||
gdk = "0.8.0"
|
||||
gdk-pixbuf = "0.4.0"
|
||||
glib = "0.5.0"
|
||||
@@ -21,10 +21,11 @@ send-cell = "0.1.2"
|
||||
url = "1.7.0"
|
||||
failure = "0.1.1"
|
||||
failure_derive = "0.1.1"
|
||||
take_mut = "0.2.1"
|
||||
take_mut = "0.2.2"
|
||||
regex = "0.2.10"
|
||||
reqwest = "0.8.5"
|
||||
serde_json = "1.0.11"
|
||||
serde_json = "1.0.13"
|
||||
html2pango = { git = "https://gitlab.gnome.org/World/html2pango" }
|
||||
|
||||
[dependencies.gtk]
|
||||
features = ["v3_22"]
|
||||
@@ -39,3 +40,6 @@ path = "../hammond-data"
|
||||
|
||||
[dependencies.hammond-downloader]
|
||||
path = "../hammond-downloader"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "0.5.1"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.21.0
|
||||
<!-- Generated with glade 3.22.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
@@ -31,6 +31,7 @@ Tobias Bernard
|
||||
<!-- interface-copyright 2017 - 2018 -->
|
||||
<!-- interface-authors Jordan Petridis\nTobias Bernard -->
|
||||
<object class="GtkBox" id="episode_container">
|
||||
<property name="height_request">64</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="valign">center</property>
|
||||
@@ -288,9 +289,8 @@ Tobias Bernard
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="padding">6</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
@@ -300,14 +300,13 @@ Tobias Bernard
|
||||
<property name="no_show_all">True</property>
|
||||
<property name="margin_left">6</property>
|
||||
<property name="margin_right">6</property>
|
||||
<property name="margin_bottom">6</property>
|
||||
<property name="hexpand">True</property>
|
||||
<property name="pulse_step">0</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="padding">6</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated with glade 3.21.0
|
||||
<!-- Generated with glade 3.22.0
|
||||
|
||||
Copyright (C) 2017 - 2018
|
||||
|
||||
@@ -33,14 +33,15 @@ Tobias Bernard
|
||||
<object class="GtkBox" id="container">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="valign">center</property>
|
||||
<property name="margin_top">6</property>
|
||||
<property name="margin_bottom">6</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkImage" id="cover">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="margin_left">6</property>
|
||||
<property name="margin_top">6</property>
|
||||
<property name="margin_bottom">6</property>
|
||||
<property name="pixel_size">64</property>
|
||||
<property name="icon_name">image-x-generic-symbolic</property>
|
||||
</object>
|
||||
|
||||
@@ -112,12 +112,10 @@ Tobias Bernard
|
||||
<property name="halign">start</property>
|
||||
<property name="valign">end</property>
|
||||
<property name="label">foo</property>
|
||||
<property name="use_markup">True</property>
|
||||
<property name="wrap">True</property>
|
||||
<property name="wrap_mode">word-char</property>
|
||||
<property name="max_width_chars">90</property>
|
||||
<attributes>
|
||||
<attribute name="weight" value="medium"/>
|
||||
</attributes>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
|
||||
@@ -5,19 +5,32 @@
|
||||
<project_license>GPL-3.0</project_license>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<developer_name>Jordan Petridis</developer_name>
|
||||
<summary>GNOME Podcast Client written in Rust</summary>
|
||||
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
|
||||
<description>
|
||||
Hammond is a Gtk+ Podcast client for the GNOME Desktop written in Rust
|
||||
<summary>Modern Podcast Client for the GNOME desktop</summary>
|
||||
<description xml:lang="en">
|
||||
<p>
|
||||
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
|
||||
desktop written in Rust.
|
||||
</p>
|
||||
</description>
|
||||
<url type="homepage">https://gitlab.gnome.org/alatiera/Hammond</url>
|
||||
<screenshots>
|
||||
<screenshot>
|
||||
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcasts_view.png</image>
|
||||
<image type="source">https://gitlab.gnome.org/alatiera/Hammond/raw/master/screenshots/podcast_widget.png</image>
|
||||
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/episodes_view.png</image>
|
||||
<caption>Page 1</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/shows_view.png</image>
|
||||
<caption>Page 2</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://gitlab.gnome.org/alatiera/hammond/raw/master/screenshots/show_widget.png</image>
|
||||
<caption>Page 3</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<releases>
|
||||
<release version="0.3.1" date="2018-03-28"/>
|
||||
</releases>
|
||||
<update_contact>jordanpetridis@protonmail.com</update_contact>
|
||||
</component>
|
||||
<url type="homepage">https://gitlab.gnome.org/alatiera/hammond</url>
|
||||
<update_contact>jpetridis@gnome.org</update_contact>
|
||||
<developer_name>Jordan Petridis and others</developer_name>
|
||||
</component>
|
||||
@@ -7,6 +7,7 @@ use gtk::SettingsExt as GtkSettingsExt;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
|
||||
use hammond_data::{Podcast, Source};
|
||||
use hammond_data::utils::delete_show;
|
||||
@@ -20,7 +21,6 @@ use widgets::mark_all_watched;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -222,7 +222,7 @@ impl App {
|
||||
}
|
||||
|
||||
// Spawn a thread so it won't block the ui.
|
||||
thread::spawn(clone!(pd => move || {
|
||||
rayon::spawn(clone!(pd => move || {
|
||||
if let Err(err) = delete_show(&pd) {
|
||||
error!("Something went wrong trying to remove {}", pd.title());
|
||||
error!("Error: {}", err);
|
||||
|
||||
@@ -19,22 +19,26 @@ extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
extern crate chrono;
|
||||
extern crate dissolve;
|
||||
extern crate crossbeam_channel;
|
||||
extern crate hammond_data;
|
||||
extern crate hammond_downloader;
|
||||
extern crate html2pango;
|
||||
extern crate humansize;
|
||||
extern crate loggerv;
|
||||
extern crate open;
|
||||
extern crate rayon;
|
||||
extern crate regex;
|
||||
extern crate reqwest;
|
||||
extern crate send_cell;
|
||||
extern crate serde_json;
|
||||
extern crate take_mut;
|
||||
extern crate url;
|
||||
// extern crate rayon;
|
||||
|
||||
// use rayon::prelude::*;
|
||||
use log::Level;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
+17
-21
@@ -1,4 +1,5 @@
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
|
||||
// use hammond_data::Episode;
|
||||
use hammond_data::dbqueries;
|
||||
@@ -11,7 +12,6 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::mpsc::Sender;
|
||||
// use std::sync::atomic::AtomicUsize;
|
||||
// use std::path::PathBuf;
|
||||
use std::thread;
|
||||
|
||||
// This is messy, undocumented and hacky af.
|
||||
// I am terrible at writting downloaders and download managers.
|
||||
@@ -75,40 +75,35 @@ impl DownloadProgress for Progress {
|
||||
lazy_static! {
|
||||
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> =
|
||||
{ Arc::new(RwLock::new(HashMap::new())) };
|
||||
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||
}
|
||||
|
||||
pub fn add(id: i32, directory: &str, sender: Sender<Action>) -> Result<(), Error> {
|
||||
pub fn add(id: i32, directory: String, sender: Sender<Action>) -> Result<(), Error> {
|
||||
// Create a new `Progress` struct to keep track of dl progress.
|
||||
let prog = Arc::new(Mutex::new(Progress::default()));
|
||||
|
||||
{
|
||||
let mut m = ACTIVE_DOWNLOADS
|
||||
.write()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the mutex."))?;
|
||||
m.insert(id, prog.clone());
|
||||
}
|
||||
match ACTIVE_DOWNLOADS.write() {
|
||||
Ok(mut guard) => guard.insert(id, prog.clone()),
|
||||
Err(err) => return Err(format_err!("ActiveDonwloads: {}.", err)),
|
||||
};
|
||||
|
||||
let dir = directory.to_owned();
|
||||
thread::spawn(move || {
|
||||
DLPOOL.spawn(move || {
|
||||
if let Ok(episode) = dbqueries::get_episode_from_rowid(id) {
|
||||
let pid = episode.podcast_id();
|
||||
let id = episode.rowid();
|
||||
|
||||
if let Err(err) = get_episode(&mut episode.into(), dir.as_str(), Some(prog)) {
|
||||
if let Err(err) = get_episode(&mut episode.into(), directory.as_str(), Some(prog)) {
|
||||
error!("Error while trying to download an episode");
|
||||
error!("Error: {}", err);
|
||||
}
|
||||
|
||||
{
|
||||
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
|
||||
info!("Removed: {:?}", m.remove(&id));
|
||||
}
|
||||
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
|
||||
let foo = m.remove(&id);
|
||||
debug!("Removed: {:?}", foo);
|
||||
}
|
||||
|
||||
// {
|
||||
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
|
||||
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
|
||||
// }
|
||||
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
|
||||
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
|
||||
// }
|
||||
|
||||
sender
|
||||
@@ -162,13 +157,14 @@ mod tests {
|
||||
let (sender, _rx) = channel();
|
||||
|
||||
let download_fold = get_download_folder(&pd.title()).unwrap();
|
||||
add(episode.rowid(), download_fold.as_str(), sender).unwrap();
|
||||
let fold2 = download_fold.clone();
|
||||
add(episode.rowid(), download_fold, sender).unwrap();
|
||||
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
|
||||
|
||||
// Give it soem time to download the file
|
||||
thread::sleep(time::Duration::from_secs(20));
|
||||
|
||||
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
|
||||
let final_path = format!("{}/{}.mp3", &fold2, episode.rowid());
|
||||
assert!(Path::new(&final_path).exists());
|
||||
fs::remove_file(final_path).unwrap();
|
||||
}
|
||||
|
||||
+119
-51
@@ -1,8 +1,13 @@
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
||||
|
||||
use failure::Error;
|
||||
use gdk_pixbuf::Pixbuf;
|
||||
use gio::{Settings, SettingsExt};
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
use regex::Regex;
|
||||
use reqwest;
|
||||
use send_cell::SendCell;
|
||||
@@ -18,8 +23,7 @@ use hammond_downloader::downloader;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::thread;
|
||||
use std::sync::mpsc::*;
|
||||
|
||||
use app::Action;
|
||||
|
||||
@@ -30,22 +34,25 @@ lazy_static! {
|
||||
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
}
|
||||
|
||||
pub fn ignore_show(id: i32) -> Result<(), Error> {
|
||||
let mut guard = IGNORESHOWS.lock().map_err(|err| format_err!("{}", err))?;
|
||||
guard.insert(id);
|
||||
Ok(())
|
||||
pub fn ignore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.insert(id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn uningore_show(id: i32) -> Result<(), Error> {
|
||||
let mut guard = IGNORESHOWS.lock().map_err(|err| format_err!("{}", err))?;
|
||||
guard.remove(&id);
|
||||
Ok(())
|
||||
pub fn uningore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.remove(&id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
|
||||
let guard = IGNORESHOWS.lock().map_err(|err| format_err!("{}", err))?;
|
||||
let keys = guard.iter().cloned().collect::<Vec<_>>();
|
||||
Ok(keys)
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn cleanup(cleanup_date: DateTime<Utc>) {
|
||||
@@ -82,7 +89,7 @@ pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
|
||||
fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(), Error> {
|
||||
sender.send(Action::HeaderBarShowUpdateIndicator)?;
|
||||
|
||||
thread::spawn(move || {
|
||||
rayon::spawn(move || {
|
||||
let mut sources = source.unwrap_or_else(|| {
|
||||
dbqueries::get_sources().expect("Failed to retrieve Sources from the database.")
|
||||
});
|
||||
@@ -128,6 +135,8 @@ fn refresh_feed(source: Option<Vec<Source>>, sender: Sender<Action>) -> Result<(
|
||||
lazy_static! {
|
||||
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
|
||||
{ RwLock::new(HashMap::new()) };
|
||||
static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
|
||||
static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||
}
|
||||
|
||||
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
|
||||
@@ -137,25 +146,76 @@ lazy_static! {
|
||||
// GObjects do not implement Send trait, so SendCell is a way around that.
|
||||
// Also lazy_static requires Sync trait, so that's what the mutexes are.
|
||||
// TODO: maybe use something that would just scale to requested size?
|
||||
pub fn get_pixbuf_from_path(pd: &PodcastCoverQuery, size: u32) -> Result<Pixbuf, Error> {
|
||||
{
|
||||
let hashmap = CACHED_PIXBUFS
|
||||
.read()
|
||||
.map_err(|_| format_err!("Failed to get a lock on the pixbuf cache mutex."))?;
|
||||
if let Some(px) = hashmap.get(&(pd.id(), size)) {
|
||||
let m = px.lock()
|
||||
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
|
||||
return Ok(m.clone().into_inner());
|
||||
pub fn set_image_from_path(
|
||||
image: >k::Image,
|
||||
pd: Arc<PodcastCoverQuery>,
|
||||
size: u32,
|
||||
) -> Result<(), Error> {
|
||||
// Check if there's an active download about this show cover.
|
||||
// If there is, a callback will be set so this function will be called again.
|
||||
// If the download succedes, there should be a quick return from the pixbuf cache_image
|
||||
// If it fails another download will be scheduled.
|
||||
if let Ok(guard) = COVER_DL_REGISTRY.read() {
|
||||
if guard.contains(&pd.id()) {
|
||||
let callback = clone!(image, pd => move || {
|
||||
let _ = set_image_from_path(&image, pd.clone(), size);
|
||||
glib::Continue(false)
|
||||
});
|
||||
gtk::timeout_add(250, callback);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let img_path = downloader::cache_image(pd)?;
|
||||
let px = Pixbuf::new_from_file_at_scale(&img_path, size as i32, size as i32, true)?;
|
||||
let mut hashmap = CACHED_PIXBUFS
|
||||
.write()
|
||||
.map_err(|_| format_err!("Failed to lock pixbuf mutex."))?;
|
||||
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
|
||||
Ok(px)
|
||||
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
|
||||
// Check if the requested (cover + size) is already in the chache
|
||||
// and if so do an early return after that.
|
||||
if let Some(guard) = hashmap.get(&(pd.id(), size)) {
|
||||
guard
|
||||
.lock()
|
||||
.map_err(|err| format_err!("SendCell Mutex: {}", err))
|
||||
.and_then(|sendcell| {
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|px| image.set_from_pixbuf(px))
|
||||
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
|
||||
})?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (sender, receiver) = channel();
|
||||
let pd_ = pd.clone();
|
||||
THREADPOOL.spawn(move || {
|
||||
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
|
||||
guard.insert(pd_.id());
|
||||
}
|
||||
|
||||
let _ = sender.send(downloader::cache_image(&pd_));
|
||||
|
||||
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
|
||||
guard.remove(&pd_.id());
|
||||
}
|
||||
});
|
||||
|
||||
let image = image.clone();
|
||||
let s = size as i32;
|
||||
gtk::timeout_add(25, move || {
|
||||
if let Ok(path) = receiver.try_recv() {
|
||||
if let Ok(path) = path {
|
||||
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
|
||||
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
|
||||
hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone())));
|
||||
image.set_from_pixbuf(&px);
|
||||
}
|
||||
}
|
||||
}
|
||||
glib::Continue(false)
|
||||
} else {
|
||||
glib::Continue(true)
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -181,10 +241,10 @@ fn itunes_id_from_url(url: &str) -> Option<u32> {
|
||||
fn lookup_id(id: u32) -> Result<String, Error> {
|
||||
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
|
||||
let req: Value = reqwest::get(&url)?.json()?;
|
||||
// FIXME: First time using serde, this could be done better and avoid using [] for indexing.
|
||||
let feedurl = req["results"][0]["feedUrl"].as_str();
|
||||
let feedurl = feedurl.ok_or_else(|| format_err!("Failed to get url from itunes response"))?;
|
||||
Ok(feedurl.into())
|
||||
let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
|
||||
rssurl()
|
||||
.map(From::from)
|
||||
.ok_or_else(|| format_err!("Failed to get url from itunes response"))
|
||||
}
|
||||
|
||||
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
|
||||
@@ -200,8 +260,8 @@ pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hammond_data::Source;
|
||||
use hammond_data::dbqueries;
|
||||
// use hammond_data::Source;
|
||||
// use hammond_data::dbqueries;
|
||||
|
||||
#[test]
|
||||
fn test_time_period_to_duration() {
|
||||
@@ -221,29 +281,34 @@ mod tests {
|
||||
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
// #[test]
|
||||
// This test inserts an rss feed to your `XDG_DATA/hammond/hammond.db` so we make it explicit
|
||||
// to run it.
|
||||
#[ignore]
|
||||
fn test_get_pixbuf_from_path() {
|
||||
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
// Create and index a source
|
||||
let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
let sid = source.id();
|
||||
pipeline::run(vec![source], true).unwrap();
|
||||
// #[ignore]
|
||||
// Disabled till https://gitlab.gnome.org/alatiera/Hammond/issues/56
|
||||
// fn test_set_image_from_path() {
|
||||
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
// Create and index a source
|
||||
// let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
// let sid = source.id();
|
||||
// pipeline::run(vec![source], true).unwrap();
|
||||
|
||||
// Get the Podcast
|
||||
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
|
||||
let pxbuf = get_pixbuf_from_path(&pd.into(), 256);
|
||||
assert!(pxbuf.is_ok());
|
||||
}
|
||||
// Get the Podcast
|
||||
// let img = gtk::Image::new();
|
||||
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
|
||||
// let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
|
||||
// assert!(pxbuf.is_ok());
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_itunes_to_rss() {
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
|
||||
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
|
||||
assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
|
||||
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
|
||||
assert!(itunes_to_rss(itunes_url).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -258,5 +323,8 @@ mod tests {
|
||||
let id = 1195206601;
|
||||
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
|
||||
assert_eq!(rss_url, lookup_id(id).unwrap());
|
||||
|
||||
let id = 000000000;
|
||||
assert!(lookup_id(id).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ use hammond_data::EpisodeWidgetQuery;
|
||||
use hammond_data::dbqueries;
|
||||
|
||||
use app::Action;
|
||||
use utils::{get_ignored_shows, get_pixbuf_from_path};
|
||||
use utils::{get_ignored_shows, set_image_from_path};
|
||||
use widgets::EpisodeWidget;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -227,9 +228,7 @@ impl EpisodesViewWidget {
|
||||
}
|
||||
|
||||
fn set_cover(&self, podcast_id: i32) -> Result<(), Error> {
|
||||
let pd = dbqueries::get_podcast_cover_from_id(podcast_id)?;
|
||||
let img = get_pixbuf_from_path(&pd, 64)?;
|
||||
self.image.set_from_pixbuf(&img);
|
||||
Ok(())
|
||||
let pd = Arc::new(dbqueries::get_podcast_cover_from_id(podcast_id)?);
|
||||
set_image_from_path(&self.image, pd, 64)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ use failure::Error;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use hammond_data::Podcast;
|
||||
use hammond_data::{Podcast, PodcastCoverQuery};
|
||||
use hammond_data::dbqueries;
|
||||
|
||||
use app::Action;
|
||||
use utils::{get_ignored_shows, get_pixbuf_from_path};
|
||||
use utils::{get_ignored_shows, set_image_from_path};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::Sender;
|
||||
@@ -57,7 +57,7 @@ impl ShowsPopulated {
|
||||
let ignore = get_ignored_shows()?;
|
||||
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
|
||||
|
||||
podcasts.into_iter().map(Arc::new).for_each(|parent| {
|
||||
podcasts.into_iter().for_each(|parent| {
|
||||
let flowbox_child = ShowsChild::new(parent);
|
||||
self.flowbox.add(&flowbox_child.child);
|
||||
});
|
||||
@@ -116,25 +116,23 @@ impl Default for ShowsChild {
|
||||
}
|
||||
|
||||
impl ShowsChild {
|
||||
pub fn new(pd: Arc<Podcast>) -> ShowsChild {
|
||||
pub fn new(pd: Podcast) -> ShowsChild {
|
||||
let child = ShowsChild::default();
|
||||
child.init(pd);
|
||||
child
|
||||
}
|
||||
|
||||
fn init(&self, pd: Arc<Podcast>) {
|
||||
fn init(&self, pd: Podcast) {
|
||||
self.container.set_tooltip_text(pd.title());
|
||||
WidgetExt::set_name(&self.child, &pd.id().to_string());
|
||||
|
||||
if let Err(err) = self.set_cover(pd.clone()) {
|
||||
let pd = Arc::new(pd.into());
|
||||
if let Err(err) = self.set_cover(pd) {
|
||||
error!("Failed to set a cover: {}", err)
|
||||
}
|
||||
|
||||
WidgetExt::set_name(&self.child, &pd.id().to_string());
|
||||
}
|
||||
|
||||
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
|
||||
let image = get_pixbuf_from_path(&pd.clone().into(), 256)?;
|
||||
self.cover.set_from_pixbuf(&image);
|
||||
Ok(())
|
||||
fn set_cover(&self, pd: Arc<PodcastCoverQuery>) -> Result<(), Error> {
|
||||
set_image_from_path(&self.cover, pd, 256)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use gtk::prelude::*;
|
||||
use failure::Error;
|
||||
use humansize::FileSize;
|
||||
use open;
|
||||
use rayon;
|
||||
use take_mut;
|
||||
|
||||
use hammond_data::{EpisodeWidgetQuery, Podcast};
|
||||
@@ -195,10 +196,10 @@ impl EpisodeWidget {
|
||||
#[inline]
|
||||
fn on_download_clicked(ep: &EpisodeWidgetQuery, sender: Sender<Action>) -> Result<(), Error> {
|
||||
let pd = dbqueries::get_podcast_from_id(ep.podcast_id())?;
|
||||
let download_fold = get_download_folder(&pd.title().to_owned())?;
|
||||
let download_fold = get_download_folder(&pd.title())?;
|
||||
|
||||
// Start a new download.
|
||||
manager::add(ep.rowid(), &download_fold, sender.clone())?;
|
||||
manager::add(ep.rowid(), download_fold, sender.clone())?;
|
||||
|
||||
// Update Views
|
||||
sender.send(Action::RefreshEpisodesView)?;
|
||||
@@ -350,23 +351,63 @@ fn total_size_helper(
|
||||
// delete_local_content(&mut ep).map_err(From::from).map(|_| ())
|
||||
// }
|
||||
|
||||
pub fn episodes_listbox(pd: &Podcast, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
|
||||
let episodes = dbqueries::get_pd_episodeswidgets(pd)?;
|
||||
pub fn episodes_listbox(pd: Arc<Podcast>, sender: Sender<Action>) -> Result<gtk::ListBox, Error> {
|
||||
use crossbeam_channel::TryRecvError::*;
|
||||
use crossbeam_channel::bounded;
|
||||
|
||||
let count = dbqueries::get_pd_episodes_count(&pd)?;
|
||||
|
||||
let (sender_, receiver) = bounded(1);
|
||||
rayon::spawn(move || {
|
||||
let episodes = dbqueries::get_pd_episodeswidgets(&pd).unwrap();
|
||||
sender_
|
||||
.send(episodes)
|
||||
.expect("Something terrible happened to the channnel");
|
||||
});
|
||||
|
||||
let list = gtk::ListBox::new();
|
||||
list.set_visible(true);
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
if episodes.is_empty() {
|
||||
if count == 0 {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/empty_show.ui");
|
||||
let container: gtk::Box = builder.get_object("empty_show").unwrap();
|
||||
list.add(&container);
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
episodes.into_iter().for_each(|ep| {
|
||||
let widget = EpisodeWidget::new(ep, sender.clone());
|
||||
list.add(&widget.container);
|
||||
});
|
||||
gtk::idle_add(clone!(list => move || {
|
||||
let episodes = match receiver.try_recv() {
|
||||
Ok(e) => e,
|
||||
Err(Empty) => return glib::Continue(true),
|
||||
Err(Disconnected) => return glib::Continue(false),
|
||||
};
|
||||
|
||||
lazy_load(episodes, list.clone(), clone!(sender => move |ep| {
|
||||
EpisodeWidget::new(ep, sender.clone()).container
|
||||
}));
|
||||
|
||||
glib::Continue(false)
|
||||
}));
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
use gtk::{IsA, Widget};
|
||||
|
||||
fn lazy_load<T, U, P, Z>(data: T, container: Z, mut predicate: P)
|
||||
where
|
||||
T: IntoIterator + 'static,
|
||||
T::Item: 'static,
|
||||
Z: ContainerExt + 'static,
|
||||
P: FnMut(T::Item) -> U + 'static,
|
||||
U: IsA<Widget>,
|
||||
{
|
||||
let mut data = data.into_iter();
|
||||
gtk::idle_add(move || {
|
||||
data.next()
|
||||
.map(|x| container.add(&predicate(x)))
|
||||
.map(|_| glib::Continue(true))
|
||||
.unwrap_or(glib::Continue(false))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
use dissolve;
|
||||
use failure::Error;
|
||||
// use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use html2pango::markup_from_raw;
|
||||
use open;
|
||||
|
||||
use hammond_data::Podcast;
|
||||
use hammond_data::dbqueries;
|
||||
use hammond_data::utils::replace_extra_spaces;
|
||||
|
||||
use app::Action;
|
||||
use utils::get_pixbuf_from_path;
|
||||
use utils::set_image_from_path;
|
||||
use widgets::episode::episodes_listbox;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -74,7 +73,7 @@ impl ShowWidget {
|
||||
}
|
||||
}));
|
||||
|
||||
self.setup_listbox(&pd, sender.clone());
|
||||
self.setup_listbox(pd.clone(), sender.clone());
|
||||
self.set_description(pd.description());
|
||||
|
||||
if let Err(err) = self.set_cover(pd.clone()) {
|
||||
@@ -106,24 +105,19 @@ impl ShowWidget {
|
||||
}
|
||||
|
||||
/// Populate the listbox with the shows episodes.
|
||||
fn setup_listbox(&self, pd: &Podcast, sender: Sender<Action>) {
|
||||
fn setup_listbox(&self, pd: Arc<Podcast>, sender: Sender<Action>) {
|
||||
let listbox = episodes_listbox(pd, sender.clone());
|
||||
listbox.ok().map(|l| self.episodes.add(&l));
|
||||
}
|
||||
|
||||
/// Set the show cover.
|
||||
fn set_cover(&self, pd: Arc<Podcast>) -> Result<(), Error> {
|
||||
let image = get_pixbuf_from_path(&pd.into(), 128)?;
|
||||
self.cover.set_from_pixbuf(&image);
|
||||
Ok(())
|
||||
set_image_from_path(&self.cover, Arc::new(pd.into()), 128)
|
||||
}
|
||||
|
||||
/// Set the descripton text.
|
||||
fn set_description(&self, text: &str) {
|
||||
// TODO: Temporary solution until we render html urls/bold/italic probably with
|
||||
// markup.
|
||||
let desc = dissolve::strip_html_tags(text).join(" ");
|
||||
self.description.set_text(&replace_extra_spaces(&desc));
|
||||
self.description.set_markup(&markup_from_raw(text));
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Project xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:rdfs="http://www.w3.org/2000/01/rdf-schema#"
|
||||
xmlns:foaf="http://xmlns.com/foaf/0.1/"
|
||||
xmlns:gnome="http://api.gnome.org/doap-extensions#"
|
||||
xmlns="http://usefulinc.com/ns/doap#">
|
||||
|
||||
<name xml:lang="en">Hammond</name>
|
||||
<shortdesc xml:lang="en">
|
||||
Modern Podcast Client for the GNOME desktop
|
||||
</shortdesc>
|
||||
<description xml:lang="en">
|
||||
<p>
|
||||
Hammond is a modern, reliable, and fast Podcast Client for the GNOME
|
||||
desktop written in Rust.
|
||||
</p>
|
||||
</description>
|
||||
<!-- <homepage rdf:resource="https://wiki.gnome.org/Apps/Hammond" /> -->
|
||||
<bug-database rdf:resource="https://gitlab.gnome.org/alatiera/Hammond/issues" />
|
||||
|
||||
<category rdf:resource="http://api.gnome.org/doap-extensions#apps" />
|
||||
<programming-language>Rust</programming-language>
|
||||
|
||||
<maintainer>
|
||||
<foaf:Person>
|
||||
<foaf:name>Jordan petridis</foaf:name>
|
||||
<foaf:mbox rdf:resource="mailto:jpetridis@gnome.org" />
|
||||
<gnome:userid>jpetridis</gnome:userid>
|
||||
</foaf:Person>
|
||||
</maintainer>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user