mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-25 20:37:24 +02:00
Impleme library downloads
This commit is contained in:
parent
a21a63e4b8
commit
bf1ffef05a
13 changed files with 1231 additions and 46 deletions
1038
Cargo.lock
generated
1038
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -12,17 +12,20 @@ diesel = { version = "2.2", features = ["chrono", "sqlite"] }
|
|||
diesel_migrations = "2.2"
|
||||
formatx = "0.2"
|
||||
fragile = "2"
|
||||
futures-util = "0.3"
|
||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||
glib = { version = "0.20", features = ["v2_84"] }
|
||||
gstreamer-play = "0.23"
|
||||
gtk = { package = "gtk4", version = "0.9", features = ["v4_18", "blueprint"] }
|
||||
glib = { version = "0.20", features = ["v2_84"] }
|
||||
lazy_static = "1"
|
||||
log = "0.4"
|
||||
mpris-server = "0.8"
|
||||
once_cell = "1"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3.17"
|
||||
tokio = { version = "1", features = ["rt", "fs"] }
|
||||
tracing-subscriber = "0.3"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
zip = "2.2"
|
||||
|
|
@ -61,6 +61,12 @@ template $MusicusLibraryManager: Adw.NavigationPage {
|
|||
end-icon-name: "go-next-symbolic";
|
||||
activated => $export_archive() swapped;
|
||||
}
|
||||
|
||||
Adw.ButtonRow {
|
||||
title: _("Update default library");
|
||||
end-icon-name: "go-next-symbolic";
|
||||
activated => $update_default_library() swapped;
|
||||
}
|
||||
}
|
||||
|
||||
Gtk.Label {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ template $MusicusProcessRow: Gtk.ListBoxRow {
|
|||
xalign: 0.0;
|
||||
}
|
||||
|
||||
Gtk.Label message_label {
|
||||
wrap: true;
|
||||
xalign: 0.0;
|
||||
visible: false;
|
||||
|
||||
styles [
|
||||
"caption",
|
||||
]
|
||||
}
|
||||
|
||||
Gtk.Label success_label {
|
||||
label: _("Process finished");
|
||||
wrap: true;
|
||||
|
|
@ -31,7 +41,7 @@ template $MusicusProcessRow: Gtk.ListBoxRow {
|
|||
|
||||
styles [
|
||||
"success",
|
||||
"caption"
|
||||
"caption",
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +52,7 @@ template $MusicusProcessRow: Gtk.ListBoxRow {
|
|||
|
||||
styles [
|
||||
"error",
|
||||
"caption"
|
||||
"caption",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,8 @@ template $MusicusSearchPage: Adw.NavigationPage {
|
|||
}
|
||||
|
||||
Gtk.Stack stack {
|
||||
vhomogeneous: false;
|
||||
|
||||
Gtk.StackPage {
|
||||
name: "results";
|
||||
|
||||
|
|
@ -247,6 +249,7 @@ template $MusicusSearchPage: Adw.NavigationPage {
|
|||
icon-name: "system-search-symbolic";
|
||||
title: _("Nothing Found");
|
||||
description: _("Try a different search.");
|
||||
vexpand: true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"command": "musicus",
|
||||
"finish-args": [
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=wayland",
|
||||
"--socket=pulseaudio",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ gnome = import('gnome')
|
|||
|
||||
name = 'Musicus'
|
||||
base_id = 'de.johrpan.Musicus'
|
||||
library_url = 'https://musicus.johrpan.de/musicus_library_latest.zip'
|
||||
app_id = base_id
|
||||
path_id = '/de/johrpan/Musicus'
|
||||
profile = get_option('profile')
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ pub static VERSION: &str = @VERSION@;
|
|||
pub static PROFILE: &str = @PROFILE@;
|
||||
pub static LOCALEDIR: &str = @LOCALEDIR@;
|
||||
pub static DATADIR: &str = @DATADIR@;
|
||||
pub static LIBRARY_URL: &str = @LIBRARY_URL@;
|
||||
|
|
|
|||
128
src/library.rs
128
src/library.rs
|
|
@ -16,12 +16,17 @@ use adw::{
|
|||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::prelude::*;
|
||||
use diesel::{dsl::exists, prelude::*, sql_types, QueryDsl, SqliteConnection};
|
||||
use formatx::formatx;
|
||||
use futures_util::StreamExt;
|
||||
use gettextrs::gettext;
|
||||
use once_cell::sync::Lazy;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use zip::{write::SimpleFileOptions, ZipWriter};
|
||||
|
||||
use crate::{
|
||||
db::{self, models::*, schema::*, tables, TranslatedString},
|
||||
process::ProcessMsg,
|
||||
program::Program,
|
||||
};
|
||||
|
||||
|
|
@ -73,17 +78,17 @@ impl Library {
|
|||
}
|
||||
|
||||
/// Import from a library archive.
|
||||
pub fn import(
|
||||
pub fn import_archive(
|
||||
&self,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<async_channel::Receiver<LibraryProcessMsg>> {
|
||||
) -> Result<async_channel::Receiver<ProcessMsg>> {
|
||||
let path = path.as_ref().to_owned();
|
||||
let library_folder = PathBuf::from(&self.folder());
|
||||
let this_connection = self.imp().connection.get().unwrap().clone();
|
||||
|
||||
let (sender, receiver) = async_channel::unbounded::<LibraryProcessMsg>();
|
||||
let (sender, receiver) = async_channel::unbounded::<ProcessMsg>();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = sender.send_blocking(LibraryProcessMsg::Result(import_from_zip(
|
||||
if let Err(err) = sender.send_blocking(ProcessMsg::Result(import_from_zip(
|
||||
path,
|
||||
library_folder,
|
||||
this_connection,
|
||||
|
|
@ -96,21 +101,43 @@ impl Library {
|
|||
Ok(receiver)
|
||||
}
|
||||
|
||||
/// Import from a library archive at `url`.
|
||||
pub fn import_url(&self, url: &str) -> Result<async_channel::Receiver<ProcessMsg>> {
|
||||
let url = url.to_owned();
|
||||
let library_folder = PathBuf::from(&self.folder());
|
||||
let this_connection = self.imp().connection.get().unwrap().clone();
|
||||
|
||||
let (sender, receiver) = async_channel::unbounded::<ProcessMsg>();
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = sender.send_blocking(ProcessMsg::Result(import_from_url(
|
||||
url,
|
||||
library_folder,
|
||||
this_connection,
|
||||
&sender,
|
||||
))) {
|
||||
log::error!("Failed to send library action result: {err:?}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(receiver)
|
||||
}
|
||||
|
||||
/// Export the whole music library to an archive at `path`. If `path` already exists, it will
|
||||
/// be overwritten. The work will be done in a background thread.
|
||||
pub fn export(
|
||||
pub fn export_archive(
|
||||
&self,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<async_channel::Receiver<LibraryProcessMsg>> {
|
||||
) -> Result<async_channel::Receiver<ProcessMsg>> {
|
||||
let connection = &mut *self.imp().connection.get().unwrap().lock().unwrap();
|
||||
|
||||
let path = path.as_ref().to_owned();
|
||||
let library_folder = PathBuf::from(&self.folder());
|
||||
let tracks = tracks::table.load::<tables::Track>(connection)?;
|
||||
|
||||
let (sender, receiver) = async_channel::unbounded::<LibraryProcessMsg>();
|
||||
let (sender, receiver) = async_channel::unbounded::<ProcessMsg>();
|
||||
thread::spawn(move || {
|
||||
if let Err(err) = sender.send_blocking(LibraryProcessMsg::Result(write_zip(
|
||||
if let Err(err) = sender.send_blocking(ProcessMsg::Result(write_zip(
|
||||
path,
|
||||
library_folder,
|
||||
tracks,
|
||||
|
|
@ -1790,7 +1817,7 @@ fn write_zip(
|
|||
zip_path: impl AsRef<Path>,
|
||||
library_folder: impl AsRef<Path>,
|
||||
tracks: Vec<tables::Track>,
|
||||
sender: &async_channel::Sender<LibraryProcessMsg>,
|
||||
sender: &async_channel::Sender<ProcessMsg>,
|
||||
) -> Result<()> {
|
||||
let mut zip = zip::ZipWriter::new(BufWriter::new(fs::File::create(zip_path)?));
|
||||
|
||||
|
|
@ -1804,9 +1831,7 @@ fn write_zip(
|
|||
add_file_to_zip(&mut zip, &library_folder, &track.path)?;
|
||||
|
||||
// Ignore if the reveiver has been dropped.
|
||||
let _ = sender.send_blocking(LibraryProcessMsg::Progress(
|
||||
(index + 1) as f64 / n_tracks as f64,
|
||||
));
|
||||
let _ = sender.send_blocking(ProcessMsg::Progress((index + 1) as f64 / n_tracks as f64));
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
|
|
@ -1837,7 +1862,7 @@ fn import_from_zip(
|
|||
zip_path: impl AsRef<Path>,
|
||||
library_folder: impl AsRef<Path>,
|
||||
this_connection: Arc<Mutex<SqliteConnection>>,
|
||||
sender: &async_channel::Sender<LibraryProcessMsg>,
|
||||
sender: &async_channel::Sender<ProcessMsg>,
|
||||
) -> Result<()> {
|
||||
let now = Local::now().naive_local();
|
||||
|
||||
|
|
@ -2065,16 +2090,79 @@ fn import_from_zip(
|
|||
}
|
||||
|
||||
// Ignore if the reveiver has been dropped.
|
||||
let _ = sender.send_blocking(LibraryProcessMsg::Progress(
|
||||
(index + 1) as f64 / n_tracks as f64,
|
||||
));
|
||||
let _ = sender.send_blocking(ProcessMsg::Progress((index + 1) as f64 / n_tracks as f64));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LibraryProcessMsg {
|
||||
Progress(f64),
|
||||
Result(Result<()>),
|
||||
fn import_from_url(
|
||||
url: String,
|
||||
library_folder: impl AsRef<Path>,
|
||||
this_connection: Arc<Mutex<SqliteConnection>>,
|
||||
sender: &async_channel::Sender<ProcessMsg>,
|
||||
) -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
let _ = sender.send_blocking(ProcessMsg::Message(
|
||||
formatx!(gettext("Downloading {}"), &url).unwrap(),
|
||||
));
|
||||
|
||||
let archive_file = runtime.block_on(download_tmp_file(&url, &sender));
|
||||
|
||||
match archive_file {
|
||||
Ok(archive_file) => {
|
||||
let _ = sender.send_blocking(ProcessMsg::Message(
|
||||
formatx!(gettext("Importing downloaded library"), &url).unwrap(),
|
||||
));
|
||||
|
||||
let _ = sender.send_blocking(ProcessMsg::Result(import_from_zip(
|
||||
archive_file.path(),
|
||||
library_folder,
|
||||
this_connection,
|
||||
&sender,
|
||||
)));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = sender.send_blocking(ProcessMsg::Result(Err(err)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_tmp_file(
|
||||
url: &str,
|
||||
sender: &async_channel::Sender<ProcessMsg>,
|
||||
) -> Result<NamedTempFile> {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
let response = client.get(url).send().await?;
|
||||
let total_size = response.content_length();
|
||||
let mut body_stream = response.bytes_stream();
|
||||
|
||||
let file = NamedTempFile::new()?;
|
||||
let mut writer =
|
||||
tokio::io::BufWriter::new(tokio::fs::File::from_std(file.as_file().try_clone()?));
|
||||
|
||||
let mut downloaded = 0;
|
||||
while let Some(chunk) = body_stream.next().await {
|
||||
let chunk: Vec<u8> = chunk?.into();
|
||||
let chunk_size = chunk.len();
|
||||
|
||||
writer.write_all(&chunk).await?;
|
||||
|
||||
if let Some(total_size) = total_size {
|
||||
downloaded += chunk_size as u64;
|
||||
let _ = sender
|
||||
.send(ProcessMsg::Progress(downloaded as f64 / total_size as f64))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ use std::{cell::OnceCell, ffi::OsStr, path::Path};
|
|||
use adw::{prelude::*, subclass::prelude::*};
|
||||
use formatx::formatx;
|
||||
use gettextrs::gettext;
|
||||
use gtk::glib::{self, clone};
|
||||
use gtk::{
|
||||
gio,
|
||||
glib::{self, clone},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
library::Library, process::Process, process_manager::ProcessManager, process_row::ProcessRow,
|
||||
window::Window,
|
||||
config, library::Library, process::Process, process_manager::ProcessManager,
|
||||
process_row::ProcessRow, window::Window,
|
||||
};
|
||||
|
||||
mod imp {
|
||||
|
|
@ -125,7 +128,7 @@ impl LibraryManager {
|
|||
}
|
||||
Ok(path) => {
|
||||
if let Some(path) = path.path() {
|
||||
match self.imp().library.get().unwrap().import(&path) {
|
||||
match self.imp().library.get().unwrap().import_archive(&path) {
|
||||
Ok(receiver) => {
|
||||
let process = Process::new(
|
||||
&formatx!(
|
||||
|
|
@ -183,7 +186,7 @@ impl LibraryManager {
|
|||
}
|
||||
Ok(path) => {
|
||||
if let Some(path) = path.path() {
|
||||
match self.imp().library.get().unwrap().export(&path) {
|
||||
match self.imp().library.get().unwrap().export_archive(&path) {
|
||||
Ok(receiver) => {
|
||||
let process = Process::new(
|
||||
&formatx!(
|
||||
|
|
@ -211,6 +214,31 @@ impl LibraryManager {
|
|||
}
|
||||
}
|
||||
|
||||
#[template_callback]
|
||||
fn update_default_library(&self) {
|
||||
let settings = gio::Settings::new(config::APP_ID);
|
||||
let url = if settings.boolean("use-custom-library-url") {
|
||||
settings.string("custom-library-url").to_string()
|
||||
} else {
|
||||
config::LIBRARY_URL.to_string()
|
||||
};
|
||||
|
||||
match self.imp().library.get().unwrap().import_url(&url) {
|
||||
Ok(receiver) => {
|
||||
let process = Process::new(&gettext("Downloading music library"), receiver);
|
||||
|
||||
self.imp()
|
||||
.process_manager
|
||||
.get()
|
||||
.unwrap()
|
||||
.add_process(&process);
|
||||
|
||||
self.add_process(&process);
|
||||
}
|
||||
Err(err) => log::error!("Failed to download library: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_process(&self, process: &Process) {
|
||||
let row = ProcessRow::new(process);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ conf.set_quoted('VERSION', meson.project_version())
|
|||
conf.set_quoted('PROFILE', profile)
|
||||
conf.set_quoted('LOCALEDIR', localedir)
|
||||
conf.set_quoted('DATADIR', datadir)
|
||||
conf.set_quoted('LIBRARY_URL', library_url)
|
||||
|
||||
configure_file(
|
||||
input: 'config.rs.in',
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
use std::cell::{Cell, OnceCell, RefCell};
|
||||
|
||||
use anyhow::Result;
|
||||
use gtk::{
|
||||
glib::{self, Properties},
|
||||
prelude::*,
|
||||
subclass::prelude::*,
|
||||
};
|
||||
|
||||
use crate::library::LibraryProcessMsg;
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
|
|
@ -16,6 +15,8 @@ mod imp {
|
|||
pub struct Process {
|
||||
#[property(get, construct_only)]
|
||||
pub description: OnceCell<String>,
|
||||
#[property(get, set, nullable)]
|
||||
pub message: RefCell<Option<String>>,
|
||||
#[property(get, set)]
|
||||
pub progress: Cell<f64>,
|
||||
#[property(get, set)]
|
||||
|
|
@ -39,7 +40,7 @@ glib::wrapper! {
|
|||
}
|
||||
|
||||
impl Process {
|
||||
pub fn new(description: &str, receiver: async_channel::Receiver<LibraryProcessMsg>) -> Self {
|
||||
pub fn new(description: &str, receiver: async_channel::Receiver<ProcessMsg>) -> Self {
|
||||
let obj: Self = glib::Object::builder()
|
||||
.property("description", description)
|
||||
.build();
|
||||
|
|
@ -48,11 +49,17 @@ impl Process {
|
|||
glib::spawn_future_local(async move {
|
||||
while let Ok(msg) = receiver.recv().await {
|
||||
match msg {
|
||||
LibraryProcessMsg::Progress(fraction) => {
|
||||
ProcessMsg::Message(message) => {
|
||||
obj_clone.set_message(Some(message));
|
||||
}
|
||||
ProcessMsg::Progress(fraction) => {
|
||||
obj_clone.set_progress(fraction);
|
||||
}
|
||||
LibraryProcessMsg::Result(result) => {
|
||||
ProcessMsg::Result(result) => {
|
||||
obj_clone.set_message(None::<String>);
|
||||
|
||||
if let Err(err) = result {
|
||||
log::error!("Process \"{}\" failed: {err:?}", obj_clone.description());
|
||||
obj_clone.set_error(err.to_string());
|
||||
}
|
||||
|
||||
|
|
@ -65,3 +72,10 @@ impl Process {
|
|||
obj
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProcessMsg {
|
||||
Message(String),
|
||||
Progress(f64),
|
||||
Result(Result<()>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ mod imp {
|
|||
#[template_child]
|
||||
pub description_label: TemplateChild<gtk::Label>,
|
||||
#[template_child]
|
||||
pub message_label: TemplateChild<gtk::Label>,
|
||||
#[template_child]
|
||||
pub success_label: TemplateChild<gtk::Label>,
|
||||
#[template_child]
|
||||
pub error_label: TemplateChild<gtk::Label>,
|
||||
|
|
@ -69,6 +71,11 @@ mod imp {
|
|||
.bind_property("progress", &*self.progress_bar, "fraction")
|
||||
.build();
|
||||
|
||||
let obj = self.obj().to_owned();
|
||||
self.obj().process().connect_message_notify(move |_| {
|
||||
obj.update();
|
||||
});
|
||||
|
||||
let obj = self.obj().to_owned();
|
||||
self.obj().process().connect_finished_notify(move |_| {
|
||||
obj.update();
|
||||
|
|
@ -107,6 +114,16 @@ impl ProcessRow {
|
|||
}
|
||||
|
||||
fn update(&self) {
|
||||
match self.process().message() {
|
||||
Some(message) => {
|
||||
self.imp().message_label.set_visible(true);
|
||||
self.imp().message_label.set_label(&message);
|
||||
}
|
||||
None => {
|
||||
self.imp().message_label.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.process().finished() {
|
||||
self.imp()
|
||||
.progress_bar
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue