Commit bec8ae65 authored by Felix Häcker's avatar Felix Häcker

Kill StationModel; Implement new library backend using diesel; Make API client...

Kill StationModel; Implement new library backend using diesel; Make API client fully async; Improve search page
parent 7fca17e0
......@@ -34,3 +34,4 @@ src/config.rs
**/#*#
**/tags.*
.flatpak-builder
shortwave.db
This diff is collapsed.
......@@ -18,7 +18,6 @@ gstreamer-pbutils = "0.14.0"
mpris-player = "0.4.0"
log = "0.4.7"
pretty_env_logger = "0.3.0"
rusqlite = "0.19.0"
quick-error = "1.2.2"
restson = "0.5.4"
uuid = { version = "0.7.4", features = ["v4"] }
......@@ -33,4 +32,7 @@ matches = "0.1.8"
open = "1.2.3"
serde_urlencoded = "0.5.5"
url = "2.0.0"
soup = { git="https://gitlab.gnome.org/haecker-felix/soup-rs" }
diesel = { version = "1.4.2", features = ["sqlite", "r2d2"] }
diesel_migrations = "1.4.0"
xdg = "2.2.0"
soup = { git="https://gitlab.gnome.org/haecker-felix/soup-rs", features = ["futures"] }
DROP TABLE IF EXISTS library;
DROP TABLE IF EXISTS sources;
CREATE TABLE library (
id integer NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
station_id integer NOT NULL
);
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1 -->
<interface>
<requires lib="gtk+" version="3.22"/>
<requires lib="libhandy" version="0.0"/>
<object class="GtkBox" id="search">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<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>
<child>
<object class="HdySearchBar" id="search_bar">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">fill</property>
<property name="valign">start</property>
<property name="hexpand">True</property>
<property name="show-close-button">False</property>
<property name="search-mode-enabled">True</property>
<child>
<object class="HdyColumn">
<property name="visible">True</property>
<property name="maximum_width">1600</property>
<property name="linear_growth_width">400</property>
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="visible">True</property>
<property name="hexpand">True</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="shadow_type">none</property>
<child>
<object class="HdyColumn">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="maximum_width">1600</property>
<property name="linear_growth_width">400</property>
<property name="border_width">12</property>
<child>
<object class="GtkBox" id="results_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
</child>
</object>
</child>
</object>
</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">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>
......@@ -9,22 +9,6 @@
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="leaflet_breakpoint">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes"> </property>
<property name="ellipsize">start</property>
<style>
<class name="hide"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkStack" id="discover_stack">
<property name="visible">True</property>
......@@ -102,6 +86,23 @@
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="leaflet_width_breakpoint">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes"> </property>
<property name="ellipsize">start</property>
<style>
<class name="hide"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">discover</property>
......@@ -115,80 +116,7 @@
<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="orientation">vertical</property>
<child>
<object class="HdyColumn">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">12</property>
<property name="margin_right">12</property>
<property name="margin_bottom">12</property>
<property name="maximum_width">1600</property>
<property name="linear_growth_width">400</property>
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="primary_icon_name">edit-find-symbolic</property>
<property name="primary_icon_activatable">False</property>
<property name="primary_icon_sensitive">False</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="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hscrollbar_policy">never</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="shadow_type">none</property>
<child>
<object class="HdyColumn">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">12</property>
<property name="margin_right">12</property>
<property name="maximum_width">1600</property>
<property name="linear_growth_width">400</property>
<child>
<object class="GtkBox" id="results_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
</child>
</object>
</child>
</object>
</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">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
<placeholder/>
</child>
</object>
<packing>
......
......@@ -15,6 +15,7 @@
<file compressed="true" preprocess="xml-stripblanks">gtk/gtk_controller.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/station_dialog.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/tile_button.ui</file>
<file compressed="true" preprocess="xml-stripblanks">gtk/search.ui</file>
<file compressed="true">gtk/style.css</file>
......
......@@ -4,7 +4,7 @@
"runtime-version" : "master",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable"
"org.freedesktop.Sdk.Extension.rust-nightly"
],
"command" : "shortwave",
"tags" : [
......@@ -26,7 +26,7 @@
"--own-name=org.mpris.MediaPlayer2.Shortwave"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin",
"append-path" : "/usr/lib/sdk/rust-nightly/bin",
"build-args" : [
"--share=network"
],
......@@ -55,7 +55,8 @@
"sources" : [
{
"type" : "git",
"url" : "https://source.puri.sm/Librem5/libhandy.git"
"url" : "https://source.puri.sm/Librem5/libhandy.git",
"commit" : "2d777677352d037b6f5cc24d9c1c8d9a74ac0ded"
}
]
},
......
# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/database/schema.rs"
......@@ -8,6 +8,9 @@ export SHORTWAVE_PROFILE="$5"
export CARGO_TARGET_DIR="$MESON_BUILD_ROOT"/target
export CARGO_HOME="$CARGO_TARGET_DIR"/cargo-home
echo "** Rust version:"
rustc --version
if test "$SHORTWAVE_PROFILE" != "Devel"
then
echo "** RELEASE MODE **"
......
use glib::Sender;
use gio::prelude::*;
use glib::GString;
use soup::prelude::*;
use soup::MessageExt;
use soup::Session;
use url::Url;
use std::cell::RefCell;
use std::rc::Rc;
use crate::api::*;
use crate::config;
use crate::model::StationModel;
use crate::database::StationIdentifier;
#[derive(Clone)]
pub struct Client {
session: Session,
server: Url,
pub model: Rc<RefCell<StationModel>>,
}
impl Client {
......@@ -24,53 +20,62 @@ impl Client {
let session = soup::Session::new();
session.set_property_user_agent(Some(&user_agent));
let model = Rc::new(RefCell::new(StationModel::new()));
debug!("Initialized new soup session with user agent \"{}\"", user_agent);
Client { server, session, model }
Client { server, session }
}
pub fn send_station_request(&self, request: &StationRequest) {
pub async fn send_station_request(self, request: StationRequest) -> Vec<Station> {
let url = self.build_url(STATION_SEARCH, Some(&request.url_encode()));
debug!("Station request URL: {}", url);
let data = self.send_message(url).await.unwrap().0;
// Create SOUP message
let message = soup::Message::new("GET", &url.to_string()).unwrap();
// Parse text to Vec<Station>
let stations: Vec<Station> = serde_json::from_str(data.as_str()).unwrap();
debug!("Found {} station(s)!", stations.len());
// Send created message
let model = self.model.clone();
self.session.queue_message(&message, move |_, response| {
model.borrow_mut().clear();
stations
}
let response_data = response.get_property_response_body_data().unwrap();
let response_text = std::str::from_utf8(&response_data).unwrap();
pub async fn get_stations_by_identifiers(self, identifiers: Vec<StationIdentifier>) -> Vec<Station> {
let mut stations = Vec::new();
// Parse result text
let result: Vec<Station> = serde_json::from_str(response_text).unwrap();
debug!("Found {} station(s)!", result.len());
for identifier in identifiers {
let url = self.build_url(&format!("{}{}", STATION_BY_ID, identifier.station_id), None);
debug!("Request station by ID URL: {}", url);
let data = self.send_message(url).await.unwrap().0;
for station in result {
model.borrow_mut().add_station(station);
}
});
// Parse text to Vec<Station>
let mut s: Vec<Station> = serde_json::from_str(data.as_str()).unwrap();
stations.append(&mut s);
}
debug!("Found {} station(s)!", stations.len());
stations
}
pub fn get_stream_url(&self, station: &Station, sender: Sender<String>) {
pub async fn get_stream_url(self, station: Station) -> StationUrl {
let url = self.build_url(&format!("{}{}", PLAYABLE_STATION_URL, station.id), None);
debug!("Request playable URL: {}", url);
let data = self.send_message(url).await.unwrap().0;
// Parse text to StationUrl
let result: Vec<StationUrl> = serde_json::from_str(data.as_str()).unwrap();
debug!("Playable URL is: {}", result[0].url);
result[0].clone()
}
// Create and send soup message, return the received data.
async fn send_message(&self, url: Url) -> Result<(GString, usize), gio::Error> {
// Create SOUP message
let message = soup::Message::new("GET", &url.to_string()).unwrap();
let sender = sender.clone();
self.session.queue_message(&message, move |_, response| {
let response_data = response.get_property_response_body_data().unwrap();
let response_text = std::str::from_utf8(&response_data).unwrap();
// Parse result text
let result: Vec<StationUrl> = serde_json::from_str(response_text).unwrap();
debug!("Playable URL is: {}", result[0].url);
sender.send(result[0].url.clone()).unwrap();
});
// Send created message
let input_stream = self.session.send_async_future(&message).await.unwrap();
// Create DataInputStream and read read received data
let data_input_stream = gio::DataInputStream::new(&input_stream);
data_input_stream.read_upto_async_future("", glib::PRIORITY_LOW).await
}
fn build_url(&self, param: &str, options: Option<&str>) -> Url {
......
static STATION_SEARCH: &'static str = "json/stations/search";
static STATION_BY_ID: &'static str = "json/stations/byid/";
static PLAYABLE_STATION_URL: &'static str = "json/url/";
mod client;
......
use serde::{de, Deserialize, Deserializer};
use std::str::FromStr;
#[derive(Serialize, Deserialize, Debug, Clone, Eq, Hash)]
pub struct Station {
pub id: String,
#[serde(deserialize_with = "de_from_str")]
pub id: i32,
pub changeuuid: String,
pub stationuuid: String,
pub name: String,
......@@ -11,7 +15,8 @@ pub struct Station {
pub country: String,
pub state: String,
pub language: String,
pub votes: String,
#[serde(deserialize_with = "de_from_str")]
pub votes: i32,
pub negativevotes: String,
pub lastchangetime: String,
pub ip: String,
......@@ -22,8 +27,10 @@ pub struct Station {
pub lastchecktime: String,
pub lastcheckoktime: String,
pub clicktimestamp: String,
pub clickcount: String,
pub clicktrend: String,
#[serde(deserialize_with = "de_from_str")]
pub clickcount: i32,
#[serde(deserialize_with = "de_from_str")]
pub clicktrend: i32,
}
impl PartialEq for Station {
......@@ -31,3 +38,11 @@ impl PartialEq for Station {
self.id == other.id
}
}
fn de_from_str<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
i32::from_str(&s).map_err(de::Error::custom)
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct StationUrl {
pub ok: String,
pub message: String,
......
......@@ -252,43 +252,12 @@ impl App {
filter.add_mime_type("application/x-sqlite3"); // Old Gradio library format
filter.add_mime_type("application/vnd.sqlite3"); // Old Gradio library format
if gtk::ResponseType::from(import_dialog.run()) == gtk::ResponseType::Accept {
let path = import_dialog.get_file().unwrap().get_path().unwrap();
debug!("Import path: {:?}", path);
match Library::read(path) {
Ok(stations) => {
let message = format!("Successfully imported {} stations.", stations.len());
self.sender.send(Action::ViewShowNotification(message)).unwrap();
self.sender.send(Action::LibraryAddStations(stations)).unwrap();
}
Err(error) => {
let message = format!("Could not import stations: {}", error.to_string());
self.sender.send(Action::ViewShowNotification(message)).unwrap();
}
};
}
import_dialog.destroy();
// TODO: Reimplement station import
}
fn export_stations(&self) {
let export_dialog = gtk::FileChooserNative::new(Some("Export database"), Some(&self.window.widget), gtk::FileChooserAction::Save, Some("Export"), Some("Cancel"));
export_dialog.set_current_name("library.json");
if gtk::ResponseType::from(export_dialog.run()) == gtk::ResponseType::Accept {
let path = export_dialog.get_file().unwrap().get_path().unwrap();
debug!("Export path: {:?}", path);
let stations = self.library.to_vec();
let count = stations.len();
match Library::write(stations, path) {
Ok(()) => {
let message = format!("Successfully exported {} stations.", count);
self.sender.send(Action::ViewShowNotification(message)).unwrap();
}
Err(error) => {
let message = format!("Could not export stations: {}", error.to_string());
self.sender.send(Action::ViewShowNotification(message)).unwrap();
}
};
}
export_dialog.destroy();
// TODO: Reimplement station export
}
}
use gio::prelude::*;
use glib::futures::FutureExt;
use glib::{Receiver, Sender};
use gtk::prelude::*;
use url::Url;
......@@ -14,8 +15,8 @@ use crate::app::Action;
use crate::audio::controller::{Controller, GtkController, MprisController};
use crate::audio::gstreamer_backend::{GstreamerBackend, GstreamerMessage};
use crate::audio::{PlaybackState, Song};
use crate::config;
use crate::model::SongModel;
use crate::path;
use crate::ui::SongListBox;
////////////////////////////////////////////////////////////////////////////////////
......@@ -41,7 +42,7 @@ use crate::ui::SongListBox;
pub struct Player {
pub widget: gtk::Box,
controller: Rc<Vec<Box<Controller>>>,
controller: Rc<Vec<Box<dyn Controller>>>,
backend: Arc<Mutex<GstreamerBackend>>,
song_model: Rc<RefCell<SongModel>>,
......@@ -61,7 +62,7 @@ impl Player {
let (gst_sender, gst_receiver) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
let backend = Arc::new(Mutex::new(GstreamerBackend::new(gst_sender)));
let mut controller: Vec<Box<Controller>> = Vec::new();
let mut controller: Vec<Box<dyn Controller>> = Vec::new();
// Gtk Controller
let gtk_controller = GtkController::new(sender.clone());
......@@ -73,7 +74,7 @@ impl Player {
let mpris_controller = MprisController::new(sender.clone());
controller.push(Box::new(mpris_controller));
let controller: Rc<Vec<Box<Controller>>> = Rc::new(controller);
let controller: Rc<Vec<Box<dyn Controller>>> = Rc::new(controller);
let player = Self {
widget,
......@@ -94,16 +95,16 @@ impl Player {
con.set_station(station.clone());
}
let (sender, receiver) = glib::MainContext::channel(glib::PRIORITY_LOW);
let client = Client::new(Url::parse("http://www.radio-browser.info/webservice/").unwrap());
client.get_stream_url(&station, sender);
let backend = self.backend.clone();
receiver.attach(None, move |station_url| {
debug!("new source uri to record: {}", station_url);
backend.lock().unwrap().new_source_uri(&station_url);
glib::Continue(false)
let client = Client::new(Url::parse("http://www.radio-browser.info/webservice/").unwrap());
// get asynchronously the stream url and play it
let fut = client.get_stream_url(station).map(move |station_url| {
debug!("new source uri to record: {}", station_url.url);
backend.lock().unwrap().new_source_uri(&station_url.url);
});
let ctx = glib::MainContext::default();
ctx.spawn_local(fut);
}
pub fn set_playback(&self, playback: PlaybackState) {
......@@ -142,7 +143,7 @@ impl Player {
});
}
fn process_gst_message(message: GstreamerMessage, controller: Rc<Vec<Box<Controller>>>, song_model: Rc<RefCell<SongModel>>, backend: Arc<Mutex<GstreamerBackend>>) -> glib::Continue {
fn process_gst_message(message: GstreamerMessage, controller: Rc<Vec<Box<dyn Controller>>>, song_model: Rc<RefCell<SongModel>>, backend: Arc<Mutex<GstreamerBackend>>) -> glib::Continue {
match message {
GstreamerMessage::SongTitleChanged(title) => {
debug!("Song title has changed: \"{}\"", title);
......@@ -192,8 +193,7 @@ impl Player {
fn get_song_path(title: String) -> PathBuf {
let title = Song::simplify_title(title);
let mut path = glib::get_user_cache_dir().unwrap();
path.push(config::NAME);
let mut path = path::CACHE.clone();
path.push("recording");
// Make sure that the path exists
......@@ -203,6 +203,6 @@ impl Player {
path.push(title);
path.set_extension("ogg");
}
path
path.to_path_buf()
}
}
// Based on gnome-podcasts by Jordan Petridis
// https://gitlab.gnome.org/World/podcasts/blob/cf644d508d8d7dab3c9357d12b1262ae6b44c8e8/podcasts-data/src/database.rs
use crate::config;
use crate::path;
use std::io;
use std::path::PathBuf;
use diesel::prelude::*;
use diesel::r2d2;
use diesel::r2d2::ConnectionManager;
// Read database migrations
embed_migrations!("./data/database/migrations/");
// Define 'Pool' type
type Pool = r2d2::Pool<ConnectionManager<SqliteConnection>>;
lazy_static! {
// Database path
pub static ref DB_PATH: PathBuf = path::BASE.place_data_file(format!("{}.db",config::NAME)).unwrap();
// Database R2D2 connection pool
static ref POOL: Pool = init_connection_pool(DB_PATH.to_str().unwrap());
}
// Returns a R2D2 SqliteConnection
pub fn get_connection() -> Pool {
POOL.clone()
}
// Inits database connection pool, and run migrations.
// If there's no database, it get's created automatically.
fn init_connection_pool(db_path: &str) -> Pool {
let manager = ConnectionManager::<SqliteConnection>::new(db_path);
let pool = r2d2::Pool::builder().max_size(1).build(manager).expect("Failed to create pool.");
let db = pool.get().expect("Failed to initialize pool.");
run_migrations(&*db).expect("Failed to run migrations during init.");
info!("Initialized database connection pool.");
pool
}
fn run_migrations(connection: &SqliteConnection) -> Result<(), diesel::migration::RunMigrationsError> {
info!("Running DB Migrations...");
embedded_migrations::run_with_output(connection, &mut io::stdout()).map_err(From::from)
}
use gio::prelude::*;
use glib::futures::FutureExt;
use glib::Sender;
use gtk::prelude::*;
use url::Url;
use std::cell::RefCell;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::result::Result;
use std::rc::Rc;
use crate::api::Station;
use crate::api::{Client, Station};
use crate::app::Action;
use crate::config;
use crate::model::ObjectWrapper;
use crate::model::StationModel;
use crate::database::connection;
use crate::database::queries;
use crate::database::StationIdentifier;
use crate::model::{Order, Sorting};
use crate::ui::StationFlowBox;
lazy_static! {
static ref LIBRARY_PATH: PathBuf = {
let mut path = glib::get_user_data_dir().unwrap();
path.push(config::NAME);
path.push("library.json");
path
};
}
pub struct Library {
pub widget: gtk::Box,
library_model: RefCell<StationModel>,
flowbox: Rc<StationFlowBox>,
client: Client,
sender: Sender<Action>,
}