Compare commits
2 Commits
d8eaea70ca
...
8c2874d2ca
| Author | SHA1 | Date |
|---|---|---|
|
|
8c2874d2ca | |
|
|
cc46fe2fd0 |
|
|
@ -7,12 +7,8 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argh = "0.1.12"
|
argh = "0.1.12"
|
||||||
bincode = "1.3.3"
|
|
||||||
crc32fast = "1.3.2"
|
|
||||||
crossterm = "0.27.0"
|
crossterm = "0.27.0"
|
||||||
interprocess = "1.2.1"
|
|
||||||
opus = "0.3.0"
|
opus = "0.3.0"
|
||||||
ratatui = "0.23.0"
|
ratatui = "0.23.0"
|
||||||
rodio = {version = "0.17.3", features = [ "symphonia-all" ], default-features = false }
|
rodio = { version = "0.17.3", features = [ "symphonia-all" ], default-features = false }
|
||||||
serde = "1.0.196"
|
|
||||||
symphonia = "0.5.4"
|
symphonia = "0.5.4"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use std::{path::PathBuf, time::Duration};
|
use std::{path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use crate::server::playback::Playback;
|
|
||||||
|
|
||||||
use rmp::TrackChangeOptions;
|
use rmp::TrackChangeOptions;
|
||||||
|
|
||||||
|
use crate::playback::Playback;
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub options: AppOptions,
|
pub options: AppOptions,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
|
@ -66,7 +66,11 @@ impl App {
|
||||||
pub fn on_down(&mut self) {}
|
pub fn on_down(&mut self) {}
|
||||||
|
|
||||||
pub fn on_enter(&mut self) {
|
pub fn on_enter(&mut self) {
|
||||||
self.play("".into()); // TODO: Remove hardcoding
|
self.play(
|
||||||
|
// "".into()
|
||||||
|
// "/home/hheikkinen/Music/Casiopea - Mint Jams (1982) FULL ALBUM/001 Take Me.opus".into(),
|
||||||
|
"/home/hheikkinen/Music/A Groovy Thing/01 - Flamingosis - A Groovy Intro.mp3".into(),
|
||||||
|
); // TODO: Remove hardcoding
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_tab(&mut self) {}
|
pub fn on_tab(&mut self) {}
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
use std::{error::Error, time::Duration};
|
use std::{error::Error, time::Duration};
|
||||||
|
|
||||||
use crate::CliArgs;
|
use crate::{
|
||||||
|
app::{App, AppOptions},
|
||||||
use self::app::{App, AppOptions};
|
crossterm, CliArgs,
|
||||||
|
};
|
||||||
pub mod app;
|
|
||||||
pub mod crossterm;
|
|
||||||
pub mod request_queue;
|
|
||||||
pub mod ui;
|
|
||||||
|
|
||||||
pub fn run(args: CliArgs) -> Result<(), Box<dyn Error>> {
|
pub fn run(args: CliArgs) -> Result<(), Box<dyn Error>> {
|
||||||
let options = AppOptions {
|
let options = AppOptions {
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
use std::{
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use interprocess::local_socket::LocalSocketStream;
|
|
||||||
use rmp::{
|
|
||||||
protocol::{self, Message, MessageError, MessageType},
|
|
||||||
ServerState,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn request_queue_cleaner(
|
|
||||||
message_rate: Duration,
|
|
||||||
queue: Arc<Mutex<Vec<Message>>>,
|
|
||||||
state: Arc<Mutex<Option<ServerState>>>,
|
|
||||||
) {
|
|
||||||
let mut last_tick = Instant::now();
|
|
||||||
let mut should_connect = true;
|
|
||||||
let mut stream: Option<LocalSocketStream> = None;
|
|
||||||
loop {
|
|
||||||
if should_connect {
|
|
||||||
*state.lock().unwrap() = None;
|
|
||||||
stream = Some(connect());
|
|
||||||
queue.lock().unwrap().push(Message {
|
|
||||||
message_type: MessageType::StateFetch,
|
|
||||||
body: None,
|
|
||||||
});
|
|
||||||
should_connect = false;
|
|
||||||
}
|
|
||||||
match stream.as_mut() {
|
|
||||||
Some(mut stream) => {
|
|
||||||
for request in queue.lock().unwrap().drain(..) {
|
|
||||||
if let Err(_) = protocol::send(&mut stream, &request) {
|
|
||||||
should_connect = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(response) = protocol::receive(&mut stream) {
|
|
||||||
match route_response(&response, &mut state.lock().unwrap()) {
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("{error:?}")
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sleep_duration = message_rate
|
|
||||||
.checked_sub(last_tick.elapsed())
|
|
||||||
.unwrap_or_else(|| Duration::from_secs(0));
|
|
||||||
std::thread::sleep(sleep_duration);
|
|
||||||
last_tick = Instant::now();
|
|
||||||
}
|
|
||||||
None => should_connect = true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocks thread until connected to socket
|
|
||||||
fn connect() -> LocalSocketStream {
|
|
||||||
let path = rmp::os::server::get_socket_path().unwrap();
|
|
||||||
loop {
|
|
||||||
match LocalSocketStream::connect(path.clone()) {
|
|
||||||
Ok(stream) => return stream,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(100));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn route_response(response: &Message, state: &mut Option<ServerState>) -> Result<(), String> {
|
|
||||||
match response.message_type {
|
|
||||||
MessageType::StateResponse => {
|
|
||||||
let body = response.body.as_ref().ok_or("Missing response body")?;
|
|
||||||
let response: ServerState =
|
|
||||||
bincode::deserialize(&body).map_err(|err| err.to_string())?;
|
|
||||||
*state = Some(response);
|
|
||||||
}
|
|
||||||
MessageType::NotImplementedAck => {
|
|
||||||
eprintln!("Server doesn't implement message")
|
|
||||||
}
|
|
||||||
MessageType::ProtocolError => {
|
|
||||||
let body = response.body.as_ref().ok_or("Missing response body")?;
|
|
||||||
let response: MessageError =
|
|
||||||
bincode::deserialize(&body).map_err(|err| err.to_string())?;
|
|
||||||
eprintln!("Server claims protocol error: {response:?}");
|
|
||||||
}
|
|
||||||
message_type => {
|
|
||||||
eprintln!("Message handling not implemented for client: {message_type:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -16,9 +16,9 @@ use symphonia::core::{
|
||||||
probe::Hint,
|
probe::Hint,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod opus;
|
use self::opus::OpusDecoder;
|
||||||
|
|
||||||
use crate::server::decoder::opus::OpusDecoder;
|
mod opus;
|
||||||
|
|
||||||
pub struct Decoder;
|
pub struct Decoder;
|
||||||
|
|
||||||
|
|
@ -2,11 +2,6 @@ use std::{path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[cfg(target_family = "unix")]
|
|
||||||
#[path = "os_unix.rs"]
|
|
||||||
pub mod os;
|
|
||||||
pub mod protocol;
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||||
pub struct ServerState {
|
pub struct ServerState {
|
||||||
pub track_change_options: TrackChangeOptions,
|
pub track_change_options: TrackChangeOptions,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
use argh::FromArgs;
|
use argh::FromArgs;
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod server;
|
pub mod crossterm;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod playback;
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
/// rmp: Rust Music Player
|
/// rmp: Rust Music Player
|
||||||
#[derive(Debug, FromArgs, Clone)]
|
#[derive(Debug, FromArgs, Clone)]
|
||||||
|
|
|
||||||
102
src/os_unix.rs
102
src/os_unix.rs
|
|
@ -1,102 +0,0 @@
|
||||||
pub mod client {
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub fn get_music_dir() -> PathBuf {
|
|
||||||
PathBuf::from(std::env!("HOME")).join("Music")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod server {
|
|
||||||
use std::{
|
|
||||||
fs,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::{id, Command, Stdio},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::server::ServerError;
|
|
||||||
|
|
||||||
pub fn reserve_pid() -> Result<(), ServerError> {
|
|
||||||
let pid_path = get_pid_path()?;
|
|
||||||
is_running()?;
|
|
||||||
|
|
||||||
fs::write(&pid_path, id().to_string()).map_err(|err| ServerError::Io(err))?;
|
|
||||||
Command::new("chmod")
|
|
||||||
.args(&["600", &pid_path.to_string_lossy()])
|
|
||||||
.output()
|
|
||||||
.map_err(|err| ServerError::Io(err))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running() -> Result<bool, ServerError> {
|
|
||||||
let pid_path = get_pid_path()?;
|
|
||||||
|
|
||||||
match fs::read(&pid_path) {
|
|
||||||
Ok(old_pid) => {
|
|
||||||
let old_pid =
|
|
||||||
String::from_utf8(old_pid).map_err(|err| ServerError::from_debuggable(err))?;
|
|
||||||
let old_pid = old_pid.trim();
|
|
||||||
Ok(Command::new("ps")
|
|
||||||
.args(&["-p", old_pid])
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.status()
|
|
||||||
.map_err(|err| ServerError::Io(err))?
|
|
||||||
.success())
|
|
||||||
}
|
|
||||||
_ => Ok(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_in_background() -> Result<(), ServerError> {
|
|
||||||
let this = std::env::args().next().unwrap();
|
|
||||||
Command::new(this)
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.args(&["-s"])
|
|
||||||
.spawn()
|
|
||||||
.map_err(|err| ServerError::Io(err))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kill() -> Result<(), ServerError> {
|
|
||||||
let pid_path = get_pid_path()?;
|
|
||||||
let socket_path = get_socket_path()?;
|
|
||||||
let pid = fs::read(&pid_path).map_err(|_| ServerError::NotRunning)?;
|
|
||||||
let pid = String::from_utf8(pid).map_err(|err| ServerError::from_debuggable(err))?;
|
|
||||||
let pid = pid.trim();
|
|
||||||
Command::new("kill")
|
|
||||||
.arg(pid)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|err| ServerError::Io(err))?;
|
|
||||||
Command::new("rm")
|
|
||||||
.args(&[
|
|
||||||
"-f",
|
|
||||||
&pid_path.to_string_lossy(),
|
|
||||||
&socket_path.to_string_lossy(),
|
|
||||||
])
|
|
||||||
.spawn()
|
|
||||||
.map_err(|err| ServerError::Io(err))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_socket_path() -> Result<PathBuf, ServerError> {
|
|
||||||
Ok(get_runtime_dir()?.join("rmp.socket"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_runtime_dir() -> Result<PathBuf, ServerError> {
|
|
||||||
let uid = String::from_utf8(
|
|
||||||
Command::new("id")
|
|
||||||
.arg("-u")
|
|
||||||
.output()
|
|
||||||
.map_err(|err| ServerError::Io(err))?
|
|
||||||
.stdout,
|
|
||||||
)
|
|
||||||
.map_err(|err| ServerError::from_debuggable(err))?;
|
|
||||||
let dir = Path::new("/run/user").join(uid.trim().to_string());
|
|
||||||
Ok(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_pid_path() -> Result<PathBuf, ServerError> {
|
|
||||||
Ok(get_runtime_dir()?.join("rmp.pid"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
137
src/protocol.rs
137
src/protocol.rs
|
|
@ -1,137 +0,0 @@
|
||||||
use std::{
|
|
||||||
fmt::Display,
|
|
||||||
io::{Read, Write},
|
|
||||||
};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::ServerState;
|
|
||||||
|
|
||||||
/// Prefix messages with this header
|
|
||||||
pub const HEADER_MAGIC: [u8; 4] = [0xCA, 0xFE, 0xBA, 0xBE];
|
|
||||||
/// Maximum allowed body size
|
|
||||||
pub const MAX_BODY_LENGTH: usize = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub enum MessageError {
|
|
||||||
HeaderMismatch,
|
|
||||||
BodySizeLimit,
|
|
||||||
ChecksumMismatch,
|
|
||||||
ReadError,
|
|
||||||
DeserializationError,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Deserialize, Serialize)]
|
|
||||||
pub enum MessageType {
|
|
||||||
/// Generic acknowledge
|
|
||||||
Ack = 0,
|
|
||||||
/// Recipient did not know how to handle the request.
|
|
||||||
NotImplementedAck,
|
|
||||||
/// Request was not a valid message. For example if the checksum did not match.
|
|
||||||
ProtocolError,
|
|
||||||
/// Request was a valid message, but it was incorrect. For example the body could be missing if
|
|
||||||
/// it was expected
|
|
||||||
ApplicationError,
|
|
||||||
StateFetch,
|
|
||||||
StateResponse,
|
|
||||||
SetShuffle,
|
|
||||||
SetNext,
|
|
||||||
SetRepeat,
|
|
||||||
SetPause,
|
|
||||||
PlayTrackFromPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct Message {
|
|
||||||
pub message_type: MessageType,
|
|
||||||
pub body: Option<Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
// pub fn new(message_type: MessageType, body: Option<Vec<u8>>) -> Self {
|
|
||||||
// Self { message_type, body }
|
|
||||||
// }
|
|
||||||
|
|
||||||
/// Message format (values are in little-endian):
|
|
||||||
/// offset | size | explanation
|
|
||||||
/// -------+------+-----------
|
|
||||||
/// 0x00 | u32 | HEADER_MAGIC
|
|
||||||
/// 0x04 | u32 | Body checksum
|
|
||||||
/// 0x08 | u32 | Body length
|
|
||||||
/// 0x12 | ? | Body
|
|
||||||
fn as_bytes(&self) -> Vec<u8> {
|
|
||||||
let magic = &HEADER_MAGIC[..];
|
|
||||||
let body = &bincode::serialize(self).unwrap();
|
|
||||||
let checksum = &crc32fast::hash(&body).to_le_bytes();
|
|
||||||
let body_length = &(body.len() as u32).to_le_bytes();
|
|
||||||
[magic, checksum, body_length, body].concat()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn state_response(server_state: &ServerState) -> Result<Self, String> {
|
|
||||||
Ok(Self {
|
|
||||||
message_type: MessageType::StateResponse,
|
|
||||||
body: Some(bincode::serialize(server_state).map_err(|err| err.to_string())?),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for Message {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{:?}\t{}",
|
|
||||||
self.message_type,
|
|
||||||
self.body
|
|
||||||
.as_ref()
|
|
||||||
.map_or("(no body)".into(), |body| format!("{} B", body.len()))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send<T>(stream: &mut T, message: &Message) -> Result<(), std::io::Error>
|
|
||||||
where
|
|
||||||
T: Write,
|
|
||||||
{
|
|
||||||
stream.write_all(&message.as_bytes())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn receive<T>(stream: &mut T) -> Result<Message, MessageError>
|
|
||||||
where
|
|
||||||
T: Read,
|
|
||||||
{
|
|
||||||
let mut magic_buffer = vec![0; HEADER_MAGIC.len()];
|
|
||||||
if let Err(_) = stream.read_exact(&mut magic_buffer) {
|
|
||||||
return Err(MessageError::ReadError);
|
|
||||||
}
|
|
||||||
if magic_buffer != HEADER_MAGIC {
|
|
||||||
return Err(MessageError::HeaderMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut checksum_buffer = [0; 4];
|
|
||||||
if let Err(_) = stream.read_exact(&mut checksum_buffer) {
|
|
||||||
return Err(MessageError::ReadError);
|
|
||||||
}
|
|
||||||
let expected_checksum = u32::from_le_bytes(checksum_buffer);
|
|
||||||
|
|
||||||
let mut body_length_buffer = [0; 4];
|
|
||||||
if let Err(_) = stream.read_exact(&mut body_length_buffer) {
|
|
||||||
return Err(MessageError::ReadError);
|
|
||||||
}
|
|
||||||
let expected_body_length = u32::from_le_bytes(body_length_buffer) as usize;
|
|
||||||
|
|
||||||
if expected_body_length > MAX_BODY_LENGTH {
|
|
||||||
return Err(MessageError::BodySizeLimit);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body_buffer = vec![0; expected_body_length];
|
|
||||||
if let Err(_) = stream.read_exact(&mut body_buffer) {
|
|
||||||
return Err(MessageError::ReadError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if crc32fast::hash(&body_buffer) != expected_checksum {
|
|
||||||
return Err(MessageError::ChecksumMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
bincode::deserialize(&body_buffer).map_err(|_| MessageError::DeserializationError)
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
use rmp::{os, server::ServerError, ServerState, TrackChangeOptions};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use crate::CliArgs;
|
|
||||||
|
|
||||||
pub mod decoder;
|
|
||||||
pub mod playback;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Server {
|
|
||||||
pub state: ServerState,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(args: CliArgs) -> Result<(), ServerError> {
|
|
||||||
if let Err(err) = os::server::reserve_pid() {
|
|
||||||
let exit_code = handle_error(err);
|
|
||||||
std::process::exit(exit_code);
|
|
||||||
}
|
|
||||||
|
|
||||||
let server = Arc::new(Mutex::new(Server {
|
|
||||||
state: ServerState {
|
|
||||||
track_change_options: TrackChangeOptions {
|
|
||||||
shuffle: args.shuffle,
|
|
||||||
next: args.next,
|
|
||||||
repeat: args.repeat,
|
|
||||||
},
|
|
||||||
player: None,
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
}));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kill() {
|
|
||||||
if let Err(err) = os::server::kill() {
|
|
||||||
let exit_code = handle_error(err);
|
|
||||||
std::process::exit(exit_code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running() -> Result<bool, ServerError> {
|
|
||||||
match os::server::is_running() {
|
|
||||||
Ok(is_running) => Ok(is_running),
|
|
||||||
Err(err) => {
|
|
||||||
let exit_code = handle_error(err);
|
|
||||||
std::process::exit(exit_code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_in_background() {
|
|
||||||
if let Err(err) = os::server::run_in_background() {
|
|
||||||
let exit_code = handle_error(err);
|
|
||||||
std::process::exit(exit_code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_error(err: ServerError) -> i32 {
|
|
||||||
match &err {
|
|
||||||
ServerError::Other(msg) => {
|
|
||||||
eprintln!("Unknown error: {msg}");
|
|
||||||
1
|
|
||||||
}
|
|
||||||
ServerError::Io(err) => {
|
|
||||||
eprintln!("IO error: {err}");
|
|
||||||
2
|
|
||||||
}
|
|
||||||
ServerError::AlreadyRunning => {
|
|
||||||
eprintln!("Server already running");
|
|
||||||
100
|
|
||||||
}
|
|
||||||
ServerError::NotRunning => {
|
|
||||||
eprintln!("Server is not running");
|
|
||||||
101
|
|
||||||
}
|
|
||||||
ServerError::MissingRuntimeDir(path) => {
|
|
||||||
eprintln!("Missing runtime directory: {}", path.to_string_lossy());
|
|
||||||
200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue