Impleme library downloads

This commit is contained in:
Elias Projahn 2025-03-23 14:57:43 +01:00
parent a21a63e4b8
commit bf1ffef05a
13 changed files with 1231 additions and 46 deletions

1038
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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 {

View file

@ -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",
]
}
}

View file

@ -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;
};
}
}

View file

@ -10,6 +10,7 @@
"command": "musicus",
"finish-args": [
"--share=ipc",
"--share=network",
"--socket=fallback-x11",
"--socket=wayland",
"--socket=pulseaudio",

View file

@ -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')

View file

@ -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@;

View file

@ -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)
}

View 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);

View file

@ -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',

View file

@ -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<()>),
}

View file

@ -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