feat: add gRPC client with config-based server address and album support

- Add tonic/prost gRPC client connecting to music-agregator service
- Add config.yaml for configurable server host/port
- Add build.rs for proto compilation from music-agregator
- Update Artist/Album models to match proto with MonitorState enum
- Convert album list from GetArtists response
- Fix album click selection with correct layout offsets
- Improve monitor state icons for better visibility
This commit is contained in:
Alexander
2026-05-08 23:10:15 +02:00
parent 620bd374de
commit e77e854d2e
14 changed files with 1812 additions and 50 deletions
Generated
+1050 -3
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -8,3 +8,12 @@ ratatui = "0.29"
crossterm = "0.28"
color-eyre = "0.6"
nix = { version = "0.29", features = ["fs"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
tonic = "0.12"
prost = "0.13"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
[build-dependencies]
tonic-build = "0.12"
+17
View File
@@ -0,0 +1,17 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let proto_root = "../music-agregator/proto";
tonic_build::configure()
.build_server(false)
.build_client(true)
.out_dir("src/proto")
.compile_protos(
&[format!(
"{}/music_agregator/v1/music_agregator.proto",
proto_root
)],
&[proto_root],
)?;
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
server:
host: localhost
port: 3000
+2
View File
@@ -61,6 +61,8 @@
clippy
rustfmt
protobuf
opencode
];
};
+122 -24
View File
@@ -8,11 +8,14 @@ use ratatui::{
widgets::{ListState, Paragraph},
};
use crate::data::{Artist, CalendarEntry, HistoryEntry, QueueEntry, WantedEntry};
use crate::data::{
Album, AlbumStatus, Artist, CalendarEntry, HistoryEntry, MonitorState, QueueEntry, WantedEntry,
};
use crate::grpc::{AlbumDetail, ArtistSummary, GrpcResponse};
use crate::theme;
use crate::ui::library::{LibraryFocus, LibraryState, render_library};
use crate::ui::modals::{ModalKind, render_help_modal, render_quit_modal};
use crate::ui::notifications::NotificationManager;
use crate::ui::notifications::{NotifKind, NotificationManager};
use crate::ui::statusbar::render_statusbar;
use crate::ui::topbar::render_topbar;
use crate::ui::views::{
@@ -286,46 +289,44 @@ impl App {
}
fn handle_library_click(&mut self, x: u16, rel_y: usize) {
let artists_width = 32u16;
const ARTISTS_PANE_WIDTH: u16 = 32;
const BORDER_TOP: usize = 1;
const HEADER_HEIGHT: usize = 6;
const DIVIDER_HEIGHT: usize = 1;
const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT;
if x < artists_width {
if x < ARTISTS_PANE_WIDTH {
if rel_y > 0 && rel_y <= self.library.artists.len() {
self.library.artist_state.select(Some(rel_y - 1));
self.library.album_state.select(Some(0));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Artists;
}
} else {
let detail_y = rel_y;
let header_height = 6;
let divider1 = 1;
let _albums_section_height = 40;
if detail_y > header_height + divider1 {
let albums_start = header_height + divider1;
let albums_rel = detail_y - albums_start;
} else if rel_y >= ALBUMS_START_ROW {
let album_row = rel_y - ALBUMS_START_ROW;
let content_height = self.main_area.height.saturating_sub(10) as usize;
let albums_section_height = (content_height * 40) / 100;
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
if rel_y < tracks_start_row {
if let Some(artist) = self.library.selected_artist() {
let album_count = artist.albums.len();
let tracks_start = albums_start + album_count.min(6) + 1;
if detail_y < tracks_start && albums_rel < album_count {
self.library.album_state.select(Some(albums_rel));
if album_row < artist.albums.len() {
self.library.album_state.select(Some(album_row));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Albums;
} else if detail_y >= tracks_start {
let tracks_rel = detail_y - tracks_start;
}
}
} else {
let track_row = rel_y - tracks_start_row;
if let Some(album) = self.library.selected_album() {
if tracks_rel < album.total as usize {
self.library.track_state.select(Some(tracks_rel));
if track_row < album.total as usize {
self.library.track_state.select(Some(track_row));
self.library.focus = LibraryFocus::Tracks;
}
}
}
}
}
}
}
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
@@ -412,6 +413,103 @@ impl App {
pub fn handle_tick(&mut self) {
self.notifications.tick();
}
pub fn set_error(&mut self, msg: String) {
self.notifications
.push("Error", Some(msg), NotifKind::Error, "");
}
pub fn handle_grpc_response(&mut self, response: GrpcResponse) {
match response {
GrpcResponse::Artists(artists) => {
let converted: Vec<Artist> = artists.into_iter().map(convert_artist).collect();
let count = converted.len();
self.library = LibraryState::new(converted);
self.notifications.push(
"Library loaded",
Some(format!("{} artists", count)),
NotifKind::Success,
"",
);
}
GrpcResponse::Error(msg) => {
self.set_error(msg);
}
}
}
}
fn convert_artist(summary: ArtistSummary) -> Artist {
let albums: Vec<Album> = summary.albums.into_iter().map(convert_album).collect();
Artist {
id: summary.id,
name: summary.name,
country: summary.country,
genres: summary.genres,
monitor_state: MonitorState::from_proto(summary.monitor_state),
path: String::new(),
quality: "FLAC".to_string(),
size_gb: 0.0,
albums,
}
}
fn convert_album(detail: AlbumDetail) -> Album {
let year = parse_year(&detail.release_date);
let monitor_state = MonitorState::from_proto(detail.monitor_state);
let monitored = monitor_state.is_monitored();
let (have, status, quality) = if let Some(download) = detail.download {
let have = if download.state == "downloaded" {
detail.total_tracks as u16
} else {
0
};
let status = match download.state.as_str() {
"downloaded" => AlbumStatus::Complete,
"downloading" => AlbumStatus::Partial,
_ => {
if monitored {
AlbumStatus::Wanted
} else {
AlbumStatus::Unmonitored
}
}
};
let quality = if download.quality.is_empty() {
"".to_string()
} else {
download.quality
};
(have, status, quality)
} else {
let status = if monitored {
AlbumStatus::Wanted
} else {
AlbumStatus::Unmonitored
};
(0, status, "".to_string())
};
Album {
id: detail.id,
title: detail.title,
year,
album_type: detail.album_type,
monitored,
total: detail.total_tracks as u16,
have,
quality,
status,
}
}
fn parse_year(date_str: &str) -> u16 {
date_str
.split('-')
.next()
.and_then(|y| y.parse().ok())
.unwrap_or(0)
}
fn scroll_list_state(state: &mut ListState, len: usize, delta: i32) {
+26
View File
@@ -0,0 +1,26 @@
use serde::Deserialize;
use std::fs;
use std::path::Path;
#[derive(Debug, Deserialize)]
pub struct Config {
pub server: ServerConfig,
}
#[derive(Debug, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?;
let config: Config = serde_yaml::from_str(&contents)?;
Ok(config)
}
pub fn grpc_addr(&self) -> String {
format!("http://{}:{}", self.server.host, self.server.port)
}
}
+27 -5
View File
@@ -1,23 +1,45 @@
#![allow(dead_code)]
//! Data models for harmony music library.
/// Album completion status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlbumStatus {
#[default]
Complete,
Partial,
Wanted,
Unmonitored,
}
/// A music artist in the library.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MonitorState {
#[default]
Unspecified,
Monitored,
Unmonitored,
Excluded,
}
impl MonitorState {
pub fn from_proto(value: i32) -> Self {
match value {
1 => MonitorState::Monitored,
2 => MonitorState::Unmonitored,
3 => MonitorState::Excluded,
_ => MonitorState::Unspecified,
}
}
pub fn is_monitored(&self) -> bool {
matches!(self, MonitorState::Monitored)
}
}
#[derive(Debug, Clone)]
pub struct Artist {
pub id: String,
pub name: String,
pub country: String,
pub genres: Vec<String>,
pub monitored: bool,
pub monitor_state: MonitorState,
pub path: String,
pub quality: String,
pub size_gb: f64,
+73
View File
@@ -0,0 +1,73 @@
use tokio::sync::mpsc;
use tonic::transport::Channel;
use crate::proto::music_agregator_v1::music_agregator_service_client::MusicAgregatorServiceClient;
pub use crate::proto::music_agregator_v1::{AlbumDetail, ArtistSummary, GetArtistsRequest};
#[derive(Debug)]
pub enum GrpcRequest {
GetArtists,
}
#[derive(Debug)]
pub enum GrpcResponse {
Artists(Vec<ArtistSummary>),
Error(String),
}
pub struct GrpcClient {
music: MusicAgregatorServiceClient<Channel>,
}
impl GrpcClient {
pub async fn connect(addr: &str) -> Result<Self, tonic::transport::Error> {
let channel = Channel::from_shared(addr.to_string())
.expect("valid uri")
.connect()
.await?;
Ok(Self {
music: MusicAgregatorServiceClient::new(channel),
})
}
pub async fn get_artists(&mut self) -> Result<Vec<ArtistSummary>, tonic::Status> {
let response = self.music.get_artists(GetArtistsRequest {}).await?;
Ok(response.into_inner().artists)
}
}
pub fn spawn_grpc_worker(
addr: String,
) -> (mpsc::Sender<GrpcRequest>, mpsc::Receiver<GrpcResponse>) {
let (req_tx, mut req_rx) = mpsc::channel::<GrpcRequest>(32);
let (resp_tx, resp_rx) = mpsc::channel::<GrpcResponse>(32);
tokio::spawn(async move {
let client = match GrpcClient::connect(&addr).await {
Ok(c) => c,
Err(e) => {
let _ = resp_tx.send(GrpcResponse::Error(e.to_string())).await;
return;
}
};
let mut client = client;
while let Some(request) = req_rx.recv().await {
let response = match request {
GrpcRequest::GetArtists => match client.get_artists().await {
Ok(artists) => GrpcResponse::Artists(artists),
Err(e) => GrpcResponse::Error(e.to_string()),
},
};
if resp_tx.send(response).await.is_err() {
break;
}
}
});
(req_tx, resp_rx)
}
+29 -5
View File
@@ -14,15 +14,21 @@ use crossterm::{
use ratatui::prelude::*;
mod app;
mod config;
mod data;
mod grpc;
mod proto;
mod theme;
mod ui;
use app::App;
use config::Config;
use grpc::{GrpcRequest, spawn_grpc_worker};
const TICK_RATE: Duration = Duration::from_millis(250);
const TICK_RATE: Duration = Duration::from_millis(100);
fn main() -> Result<()> {
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let hook = panic::take_hook();
@@ -32,7 +38,7 @@ fn main() -> Result<()> {
}));
setup_terminal()?;
let result = run();
let result = run().await;
restore_terminal()?;
result
@@ -52,23 +58,41 @@ fn restore_terminal() -> Result<()> {
Ok(())
}
fn run() -> Result<()> {
async fn run() -> Result<()> {
let config = Config::load("config.yaml").unwrap_or_else(|e| {
eprintln!("Failed to load config.yaml: {}", e);
std::process::exit(1);
});
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut app = App::new();
let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr());
if grpc_tx.send(GrpcRequest::GetArtists).await.is_err() {
app.set_error("Failed to send initial request".to_string());
}
while app.running {
terminal.draw(|frame| app.draw(frame))?;
if let Ok(response) = grpc_rx.try_recv() {
app.handle_grpc_response(response);
}
if event::poll(TICK_RATE)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
// Only handle Ctrl+C and Escape for quit
if key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('c')
{
app.running = false;
} else if key.code == KeyCode::Esc {
app.handle_escape();
} else if key.code == KeyCode::Char('r')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
let _ = grpc_tx.send(GrpcRequest::GetArtists).await;
}
}
Event::Mouse(mouse) => match mouse.kind {
+3
View File
@@ -0,0 +1,3 @@
pub mod music_agregator_v1 {
include!("music_agregator.v1.rs");
}
+417
View File
@@ -0,0 +1,417 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MonitorAlbumRequest {
#[prost(string, tag = "1")]
pub album_id: ::prost::alloc::string::String,
#[prost(message, optional, tag = "2")]
pub indexer_options: ::core::option::Option<IndexerOptions>,
#[prost(enumeration = "QualityType", tag = "3")]
pub quality: i32,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IndexerOptions {
#[prost(string, tag = "1")]
pub tracker: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MonitorAlbumResponse {
#[prost(message, optional, tag = "1")]
pub release: ::core::option::Option<MonitoredRelease>,
}
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
pub struct GetArtistsRequest {}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetArtistsResponse {
#[prost(message, repeated, tag = "1")]
pub artists: ::prost::alloc::vec::Vec<ArtistSummary>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ArtistSummary {
#[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub external_id: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub name: ::prost::alloc::string::String,
#[prost(string, tag = "4")]
pub artist_type: ::prost::alloc::string::String,
#[prost(string, tag = "5")]
pub country: ::prost::alloc::string::String,
#[prost(string, repeated, tag = "6")]
pub genres: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, tag = "7")]
pub image_url: ::prost::alloc::string::String,
#[prost(enumeration = "MonitorState", tag = "8")]
pub monitor_state: i32,
#[prost(message, repeated, tag = "9")]
pub albums: ::prost::alloc::vec::Vec<AlbumDetail>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct AlbumDetail {
#[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub external_id: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub title: ::prost::alloc::string::String,
#[prost(string, tag = "4")]
pub album_type: ::prost::alloc::string::String,
#[prost(string, tag = "5")]
pub release_date: ::prost::alloc::string::String,
#[prost(int32, tag = "6")]
pub total_tracks: i32,
#[prost(int32, tag = "7")]
pub total_discs: i32,
#[prost(string, tag = "8")]
pub cover_url: ::prost::alloc::string::String,
#[prost(string, repeated, tag = "9")]
pub genres: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, tag = "10")]
pub label: ::prost::alloc::string::String,
#[prost(enumeration = "MonitorState", tag = "11")]
pub monitor_state: i32,
#[prost(message, optional, tag = "12")]
pub download: ::core::option::Option<DownloadInfo>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DownloadInfo {
#[prost(string, tag = "1")]
pub state: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub format: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub quality: ::prost::alloc::string::String,
#[prost(string, tag = "4")]
pub save_path: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetAlbumRequest {
#[prost(string, tag = "1")]
pub album_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetAlbumResponse {
#[prost(message, optional, tag = "1")]
pub album: ::core::option::Option<AlbumDetail>,
#[prost(message, repeated, tag = "2")]
pub tracks: ::prost::alloc::vec::Vec<TrackDetail>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TrackDetail {
#[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub external_id: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub title: ::prost::alloc::string::String,
#[prost(int32, tag = "4")]
pub duration_ms: i32,
#[prost(int32, tag = "5")]
pub disc_number: i32,
#[prost(int32, tag = "6")]
pub track_number: i32,
#[prost(string, tag = "7")]
pub isrc: ::prost::alloc::string::String,
#[prost(bool, tag = "8")]
pub explicit: bool,
#[prost(message, repeated, tag = "9")]
pub artists: ::prost::alloc::vec::Vec<ArtistCredit>,
#[prost(message, optional, tag = "10")]
pub file: ::core::option::Option<TrackFile>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ArtistCredit {
#[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub name: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TrackFile {
#[prost(string, tag = "1")]
pub path: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub format: ::prost::alloc::string::String,
#[prost(int64, tag = "3")]
pub size: i64,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MonitoredRelease {
#[prost(string, tag = "1")]
pub info_hash: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub artist: ::prost::alloc::string::String,
#[prost(string, tag = "3")]
pub album: ::prost::alloc::string::String,
#[prost(int32, tag = "4")]
pub year: i32,
#[prost(string, tag = "5")]
pub format: ::prost::alloc::string::String,
#[prost(bool, tag = "6")]
pub lossless: bool,
#[prost(int32, tag = "7")]
pub bit_depth: i32,
#[prost(int32, tag = "8")]
pub sample_rate: i32,
#[prost(string, tag = "9")]
pub source: ::prost::alloc::string::String,
#[prost(int32, tag = "10")]
pub track_count: i32,
#[prost(string, repeated, tag = "11")]
pub track_names: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(bool, tag = "12")]
pub has_cover_art: bool,
#[prost(bool, tag = "13")]
pub has_cue_sheet: bool,
#[prost(bool, tag = "14")]
pub has_rip_log: bool,
#[prost(int64, tag = "15")]
pub total_audio_size: i64,
#[prost(string, tag = "16")]
pub download_link: ::prost::alloc::string::String,
#[prost(int32, tag = "17")]
pub seeders: i32,
#[prost(string, tag = "18")]
pub tracker: ::prost::alloc::string::String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum QualityType {
QualityUnspecified = 0,
QualityLossless = 1,
QualityLossy = 2,
}
impl QualityType {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::QualityUnspecified => "QUALITY_UNSPECIFIED",
Self::QualityLossless => "QUALITY_LOSSLESS",
Self::QualityLossy => "QUALITY_LOSSY",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"QUALITY_UNSPECIFIED" => Some(Self::QualityUnspecified),
"QUALITY_LOSSLESS" => Some(Self::QualityLossless),
"QUALITY_LOSSY" => Some(Self::QualityLossy),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum MonitorState {
Unspecified = 0,
Monitored = 1,
Unmonitored = 2,
Excluded = 3,
}
impl MonitorState {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "MONITOR_STATE_UNSPECIFIED",
Self::Monitored => "MONITOR_STATE_MONITORED",
Self::Unmonitored => "MONITOR_STATE_UNMONITORED",
Self::Excluded => "MONITOR_STATE_EXCLUDED",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"MONITOR_STATE_UNSPECIFIED" => Some(Self::Unspecified),
"MONITOR_STATE_MONITORED" => Some(Self::Monitored),
"MONITOR_STATE_UNMONITORED" => Some(Self::Unmonitored),
"MONITOR_STATE_EXCLUDED" => Some(Self::Excluded),
_ => None,
}
}
}
/// Generated client implementations.
pub mod music_agregator_service_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
#[derive(Debug, Clone)]
pub struct MusicAgregatorServiceClient<T> {
inner: tonic::client::Grpc<T>,
}
impl MusicAgregatorServiceClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> MusicAgregatorServiceClient<T>
where
T: tonic::client::GrpcService<tonic::body::BoxBody>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> MusicAgregatorServiceClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::BoxBody>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<
http::Request<tonic::body::BoxBody>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
MusicAgregatorServiceClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn monitor_album(
&mut self,
request: impl tonic::IntoRequest<super::MonitorAlbumRequest>,
) -> std::result::Result<
tonic::Response<super::MonitorAlbumResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/music_agregator.v1.MusicAgregatorService/MonitorAlbum",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"music_agregator.v1.MusicAgregatorService",
"MonitorAlbum",
),
);
self.inner.unary(req, path, codec).await
}
pub async fn get_artists(
&mut self,
request: impl tonic::IntoRequest<super::GetArtistsRequest>,
) -> std::result::Result<
tonic::Response<super::GetArtistsResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/music_agregator.v1.MusicAgregatorService/GetArtists",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"music_agregator.v1.MusicAgregatorService",
"GetArtists",
),
);
self.inner.unary(req, path, codec).await
}
pub async fn get_album(
&mut self,
request: impl tonic::IntoRequest<super::GetAlbumRequest>,
) -> std::result::Result<
tonic::Response<super::GetAlbumResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/music_agregator.v1.MusicAgregatorService/GetAlbum",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"music_agregator.v1.MusicAgregatorService",
"GetAlbum",
),
);
self.inner.unary(req, path, codec).await
}
}
}
+31 -11
View File
@@ -8,7 +8,7 @@ use ratatui::{
widgets::{List, ListItem, ListState, Paragraph},
};
use crate::data::{Album, AlbumStatus, Artist, Track};
use crate::data::{Album, AlbumStatus, Artist, MonitorState, Track};
use crate::theme;
use crate::ui::pane::{Pane, section_divider};
use crate::ui::progress_bar::progress_bar;
@@ -201,6 +201,20 @@ fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
}
}
fn monitor_state_icon(state: MonitorState, status: AlbumStatus) -> (char, Style) {
match state {
MonitorState::Monitored => match status {
AlbumStatus::Complete => ('✓', Style::default().fg(theme::GREEN)),
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
AlbumStatus::Wanted => ('!', Style::default().fg(theme::RED)),
AlbumStatus::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
},
MonitorState::Unmonitored => ('-', Style::default().fg(theme::GRAY)),
MonitorState::Excluded => ('x', Style::default().fg(theme::RED)),
MonitorState::Unspecified => ('?', Style::default().fg(theme::GRAY)),
}
}
fn track_icon(have: bool) -> (char, Style) {
if have {
('✓', Style::default().fg(theme::GREEN))
@@ -267,15 +281,12 @@ fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState)
.iter()
.map(|artist| {
let status = artist_status(artist);
let (icon_char, icon_style) = status_icon(status, artist.monitored);
let (icon_char, icon_style) = monitor_state_icon(artist.monitor_state, status);
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let mut name_text = artist.name.clone();
if !artist.monitored {
name_text.push_str(" ·unm");
}
let count_str = format!("{}/{}", have, total);
let name_width = inner.width as usize - 2 - count_str.len() - 2;
@@ -399,18 +410,27 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
let (status_icon, status_text, status_style) = if artist.monitored {
(
let (status_icon, status_text, status_style) = match artist.monitor_state {
MonitorState::Monitored => (
Span::styled("", Style::default().fg(theme::GREEN)),
"Monitored",
Style::default().fg(theme::FG2),
)
} else {
(
),
MonitorState::Unmonitored => (
Span::styled("", Style::default().fg(theme::GRAY)),
"Unmonitored",
Style::default().fg(theme::GRAY),
)
),
MonitorState::Excluded => (
Span::styled("", Style::default().fg(theme::RED)),
"Excluded",
Style::default().fg(theme::RED),
),
MonitorState::Unspecified => (
Span::styled("? ", Style::default().fg(theme::GRAY)),
"Unknown",
Style::default().fg(theme::GRAY),
),
};
let lines = vec![
+1
View File
@@ -5,6 +5,7 @@ pub use help::render_help_modal;
pub use quit::render_quit_modal;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ModalKind {
Help,
Quit,