Draft code to write database
This commit is contained in:
parent
3c6c093f1e
commit
02fdef444f
@ -1,4 +1,7 @@
|
|||||||
#[cfg(feature = "database-json")]
|
#[cfg(feature = "database-json")]
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
#[cfg(feature = "database-sqlite")]
|
||||||
|
pub mod sql;
|
||||||
|
|
||||||
#[cfg(feature = "database-json")]
|
#[cfg(feature = "database-json")]
|
||||||
mod serde;
|
mod serde;
|
||||||
|
@ -37,7 +37,7 @@ pub struct AlbumDateDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] AlbumDate);
|
pub struct SerdeAlbumDate(#[serde(with = "AlbumDateDef")] pub AlbumDate);
|
||||||
|
|
||||||
impl From<SerdeAlbumDate> for AlbumDate {
|
impl From<SerdeAlbumDate> for AlbumDate {
|
||||||
fn from(value: SerdeAlbumDate) -> Self {
|
fn from(value: SerdeAlbumDate) -> Self {
|
||||||
|
@ -24,22 +24,22 @@ impl<'a> From<&'a Collection> for SerializeDatabase<'a> {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SerializeArtist<'a> {
|
pub struct SerializeArtist<'a> {
|
||||||
name: &'a str,
|
pub name: &'a str,
|
||||||
sort: Option<&'a str>,
|
pub sort: Option<&'a str>,
|
||||||
musicbrainz: SerializeMbRefOption<'a>,
|
pub musicbrainz: SerializeMbRefOption<'a>,
|
||||||
properties: BTreeMap<&'a str, &'a Vec<String>>,
|
pub properties: BTreeMap<&'a str, &'a Vec<String>>,
|
||||||
albums: Vec<SerializeAlbum<'a>>,
|
pub albums: Vec<SerializeAlbum<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SerializeAlbum<'a> {
|
pub struct SerializeAlbum<'a> {
|
||||||
title: &'a str,
|
pub title: &'a str,
|
||||||
lib_id: SerdeAlbumLibId,
|
pub lib_id: SerdeAlbumLibId,
|
||||||
date: SerdeAlbumDate,
|
pub date: SerdeAlbumDate,
|
||||||
seq: u8,
|
pub seq: u8,
|
||||||
musicbrainz: SerializeMbRefOption<'a>,
|
pub musicbrainz: SerializeMbRefOption<'a>,
|
||||||
primary_type: Option<SerdeAlbumPrimaryType>,
|
pub primary_type: Option<SerdeAlbumPrimaryType>,
|
||||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
pub secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
166
src/external/database/sql/backend.rs
Normal file
166
src/external/database/sql/backend.rs
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
//! Module for storing MusicHoard data in a JSON file database.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use rusqlite::{self, CachedStatement, Connection, Params, Statement};
|
||||||
|
|
||||||
|
use crate::external::database::{
|
||||||
|
serde::serialize::{SerializeAlbum, SerializeArtist, SerializeDatabase},
|
||||||
|
sql::{Error, ISqlDatabaseBackend},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// SQLite database backend that uses SQLite as the implementation.
|
||||||
|
pub struct SqlDatabaseSqliteBackend {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlDatabaseSqliteBackend {
|
||||||
|
/// Create a [`SqlDatabaseSqliteBackend`] that will read/write to the provided database.
|
||||||
|
pub fn new<P: Into<PathBuf>>(path: P) -> Result<Self, Error> {
|
||||||
|
Ok(SqlDatabaseSqliteBackend {
|
||||||
|
conn: Connection::open(path.into()).map_err(|err| Error::OpenError(err.to_string()))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(&self, sql: &str) -> Result<Statement, Error> {
|
||||||
|
self.conn
|
||||||
|
.prepare(sql)
|
||||||
|
.map_err(|err| Error::StmtError(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_cached(&self, sql: &str) -> Result<CachedStatement, Error> {
|
||||||
|
self.conn
|
||||||
|
.prepare_cached(sql)
|
||||||
|
.map_err(|err| Error::StmtError(err.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute<P: Params>(stmt: &mut Statement, params: P) -> Result<(), Error> {
|
||||||
|
stmt.execute(params)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|err| Error::ExecError(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for Error {
|
||||||
|
fn from(value: serde_json::Error) -> Self {
|
||||||
|
Error::SerDeError(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ISqlDatabaseBackend for SqlDatabaseSqliteBackend {
|
||||||
|
fn begin_transaction(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached("BEGIN TRANSACTION;")?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_transaction(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached("COMMIT;")?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_database_metadata_table(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare(
|
||||||
|
"CREATE TABLE IF NOT EXISTS database_metadata (
|
||||||
|
name TEXT NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_database_metadata_table(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached("DROP TABLE database_metadata")?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_artists_table(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare(
|
||||||
|
"CREATE TABLE IF NOT EXISTS artists (
|
||||||
|
name TEXT NOT NULL PRIMARY KEY,
|
||||||
|
sort TEXT NULL,
|
||||||
|
mbid JSON NOT NULL DEFAULT '\"None\"',
|
||||||
|
properties JSON NOT NULL DEFAULT '{}'
|
||||||
|
)",
|
||||||
|
)?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_artists_table(&self) -> Result<(),Error> {
|
||||||
|
let mut stmt = self.prepare_cached("DROP TABLE artists")?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_albums_table(&self) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare(
|
||||||
|
"CREATE TABLE IF NOT EXISTS albums (
|
||||||
|
title TEXT NOT NULL PRIMARY KEY,
|
||||||
|
lib_id JSON NOT NULL DEFAULT '\"None\"',
|
||||||
|
mbid JSON NOT NULL DEFAULT '\"None\"',
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
year INT NULL,
|
||||||
|
month INT NULL,
|
||||||
|
day INT NULL,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
primary_type JSON NOT NULL DEFAULT 'null',
|
||||||
|
secondary_types JSON NOT NULL DEFAULT '[]',
|
||||||
|
FOREIGN KEY (artist_name) REFERENCES artists(name)
|
||||||
|
)",
|
||||||
|
)?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_albums_table(&self) -> Result<(),Error> {
|
||||||
|
let mut stmt = self.prepare_cached("DROP TABLE albums")?;
|
||||||
|
Self::execute(&mut stmt, ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_database_version<'a>(&self, version: &SerializeDatabase<'a>) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached(
|
||||||
|
"INSERT INTO database_metadata (name, value)
|
||||||
|
VALUES (?1, ?2)",
|
||||||
|
)?;
|
||||||
|
let version = match version {
|
||||||
|
SerializeDatabase::V20250103(_) => "V20250103",
|
||||||
|
};
|
||||||
|
Self::execute(&mut stmt, ("version", version))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_artist<'a>(&self, artist: &SerializeArtist<'a>) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached(
|
||||||
|
"INSERT INTO artists (name, sort, mbid, properties)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
)?;
|
||||||
|
Self::execute(
|
||||||
|
&mut stmt,
|
||||||
|
(
|
||||||
|
artist.name,
|
||||||
|
artist.sort,
|
||||||
|
serde_json::to_string(&artist.musicbrainz)?,
|
||||||
|
serde_json::to_string(&artist.properties)?,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_album<'a>(&self, artist_name: &str, album: &SerializeAlbum<'a>) -> Result<(), Error> {
|
||||||
|
let mut stmt = self.prepare_cached(
|
||||||
|
"INSERT INTO albums (title, lib_id, mbid, artist_name,
|
||||||
|
year, month, day, seq, primary_type, secondary_types)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
)?;
|
||||||
|
Self::execute(
|
||||||
|
&mut stmt,
|
||||||
|
(
|
||||||
|
album.title,
|
||||||
|
serde_json::to_string(&album.lib_id)?,
|
||||||
|
serde_json::to_string(&album.musicbrainz)?,
|
||||||
|
artist_name,
|
||||||
|
album.date.0.year,
|
||||||
|
album.date.0.month,
|
||||||
|
album.date.0.day,
|
||||||
|
album.seq,
|
||||||
|
serde_json::to_string(&album.primary_type)?,
|
||||||
|
serde_json::to_string(&album.secondary_types)?,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
263
src/external/database/sql/mod.rs
Normal file
263
src/external/database/sql/mod.rs
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
//! Module for storing MusicHoard data in a SQLdatabase.
|
||||||
|
|
||||||
|
pub mod backend;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
collection::Collection,
|
||||||
|
interface::database::{IDatabase, LoadError, SaveError},
|
||||||
|
},
|
||||||
|
external::database::serde::serialize::{SerializeAlbum, SerializeArtist, SerializeDatabase},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Trait for the SQL database backend.
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait ISqlDatabaseBackend {
|
||||||
|
/// Begin an SQL transaction.
|
||||||
|
fn begin_transaction(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Commit ongoing transaction.
|
||||||
|
fn commit_transaction(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Create the database metadata table (if needed).
|
||||||
|
fn create_database_metadata_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Drop the database metadata table.
|
||||||
|
fn drop_database_metadata_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Create the artists table (if needed).
|
||||||
|
fn create_artists_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Drop the artists table.
|
||||||
|
fn drop_artists_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Create the albums table (if needed).
|
||||||
|
fn create_albums_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Drop the albums table.
|
||||||
|
fn drop_albums_table(&self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Set the database version.
|
||||||
|
fn insert_database_version<'a>(&self, version: &SerializeDatabase<'a>) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Insert an artist into the artist table.
|
||||||
|
fn insert_artist<'a>(&self, artist: &SerializeArtist<'a>) -> Result<(), Error>;
|
||||||
|
|
||||||
|
/// Insert an artist into the artist table.
|
||||||
|
fn insert_album<'a>(&self, artist_name: &str, album: &SerializeAlbum<'a>) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors for SQL database backend.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
/// An error occurred when connecting to the database.
|
||||||
|
OpenError(String),
|
||||||
|
/// An error occurred when preparing a statement for execution.
|
||||||
|
StmtError(String),
|
||||||
|
/// An error occurred during serialisation.
|
||||||
|
SerDeError(String),
|
||||||
|
/// An error occurred during execution.
|
||||||
|
ExecError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match *self {
|
||||||
|
Self::OpenError(ref s) => {
|
||||||
|
write!(f, "an error occurred when connecting to the database: {s}")
|
||||||
|
}
|
||||||
|
Self::StmtError(ref s) => write!(
|
||||||
|
f,
|
||||||
|
"an error occurred when preparing a statement for execution: {s}"
|
||||||
|
),
|
||||||
|
Self::SerDeError(ref s) => write!(f, "an error occurred during serialisation : {s}"),
|
||||||
|
Self::ExecError(ref s) => write!(f, "an error occurred during execution: {s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Error> for SaveError {
|
||||||
|
fn from(value: Error) -> Self {
|
||||||
|
match value {
|
||||||
|
Error::SerDeError(s) => SaveError::SerDeError(s),
|
||||||
|
_ => SaveError::IoError(value.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL database.
|
||||||
|
pub struct SqlDatabase<SDB> {
|
||||||
|
backend: SDB,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<SDB: ISqlDatabaseBackend> SqlDatabase<SDB> {
|
||||||
|
/// Create a new SQL database with the provided backend, e.g.
|
||||||
|
/// [`backend::SqlDatabaseSqliteBackend`].
|
||||||
|
pub fn new(backend: SDB) -> Result<Self, Error> {
|
||||||
|
let db = SqlDatabase { backend };
|
||||||
|
db.begin_transaction()?;
|
||||||
|
db.create_tables()?;
|
||||||
|
db.commit_transaction()?;
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_transaction(&self) -> Result<(), Error> {
|
||||||
|
self.backend.begin_transaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_transaction(&self) -> Result<(), Error> {
|
||||||
|
self.backend.commit_transaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tables(&self) -> Result<(), Error> {
|
||||||
|
self.backend.create_database_metadata_table()?;
|
||||||
|
self.backend.create_artists_table()?;
|
||||||
|
self.backend.create_albums_table()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_tables(&self) -> Result<(), Error> {
|
||||||
|
self.backend.drop_database_metadata_table()?;
|
||||||
|
self.backend.drop_artists_table()?;
|
||||||
|
self.backend.drop_albums_table()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<SDB: ISqlDatabaseBackend> IDatabase for SqlDatabase<SDB> {
|
||||||
|
fn load(&self) -> Result<Collection, LoadError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, collection: &Collection) -> Result<(), SaveError> {
|
||||||
|
let database: SerializeDatabase = collection.into();
|
||||||
|
self.begin_transaction()?;
|
||||||
|
|
||||||
|
self.drop_tables()?;
|
||||||
|
self.create_tables()?;
|
||||||
|
|
||||||
|
self.backend.insert_database_version(&database)?;
|
||||||
|
match database {
|
||||||
|
SerializeDatabase::V20250103(artists) => {
|
||||||
|
for artist in artists.iter() {
|
||||||
|
self.backend.insert_artist(artist)?;
|
||||||
|
for album in artist.albums.iter() {
|
||||||
|
self.backend.insert_album(artist.name, album)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.commit_transaction()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// pub mod testmod;
|
||||||
|
|
||||||
|
// #[cfg(test)]
|
||||||
|
// mod tests {
|
||||||
|
// use std::collections::HashMap;
|
||||||
|
|
||||||
|
// use mockall::predicate;
|
||||||
|
|
||||||
|
// use crate::core::{
|
||||||
|
// collection::{artist::Artist, Collection},
|
||||||
|
// testmod::FULL_COLLECTION,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// use super::*;
|
||||||
|
// use testmod::DATABASE_JSON;
|
||||||
|
|
||||||
|
// fn expected() -> Collection {
|
||||||
|
// let mut expected = FULL_COLLECTION.to_owned();
|
||||||
|
// for artist in expected.iter_mut() {
|
||||||
|
// for album in artist.albums.iter_mut() {
|
||||||
|
// album.tracks.clear();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// expected
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn save() {
|
||||||
|
// let write_data = FULL_COLLECTION.to_owned();
|
||||||
|
// let input = DATABASE_JSON.to_owned();
|
||||||
|
|
||||||
|
// let mut backend = MockISqlDatabaseBackend::new();
|
||||||
|
// backend
|
||||||
|
// .expect_write()
|
||||||
|
// .with(predicate::eq(input))
|
||||||
|
// .times(1)
|
||||||
|
// .return_once(|_| Ok(()));
|
||||||
|
|
||||||
|
// SqlDatabase::new(backend).save(&write_data).unwrap();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn load() {
|
||||||
|
// let expected = expected();
|
||||||
|
// let result = Ok(DATABASE_JSON.to_owned());
|
||||||
|
// eprintln!("{DATABASE_JSON}");
|
||||||
|
|
||||||
|
// let mut backend = MockISqlDatabaseBackend::new();
|
||||||
|
// backend.expect_read().times(1).return_once(|| result);
|
||||||
|
|
||||||
|
// let read_data: Vec<Artist> = SqlDatabase::new(backend).load().unwrap();
|
||||||
|
|
||||||
|
// assert_eq!(read_data, expected);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn reverse() {
|
||||||
|
// let input = DATABASE_JSON.to_owned();
|
||||||
|
// let result = Ok(input.clone());
|
||||||
|
|
||||||
|
// let mut backend = MockISqlDatabaseBackend::new();
|
||||||
|
// backend
|
||||||
|
// .expect_write()
|
||||||
|
// .with(predicate::eq(input))
|
||||||
|
// .times(1)
|
||||||
|
// .return_once(|_| Ok(()));
|
||||||
|
// backend.expect_read().times(1).return_once(|| result);
|
||||||
|
// let mut database = SqlDatabase::new(backend);
|
||||||
|
|
||||||
|
// let write_data = FULL_COLLECTION.to_owned();
|
||||||
|
// database.save(&write_data).unwrap();
|
||||||
|
// let read_data: Vec<Artist> = database.load().unwrap();
|
||||||
|
|
||||||
|
// // Album information is not saved to disk.
|
||||||
|
// let expected = expected();
|
||||||
|
// assert_eq!(read_data, expected);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn load_errors() {
|
||||||
|
// let json = String::from("");
|
||||||
|
// let serde_err = serde_json::from_str::<DeserializeDatabase>(&json);
|
||||||
|
// assert!(serde_err.is_err());
|
||||||
|
|
||||||
|
// let serde_err: LoadError = serde_err.unwrap_err().into();
|
||||||
|
// assert!(!serde_err.to_string().is_empty());
|
||||||
|
// assert!(!format!("{:?}", serde_err).is_empty());
|
||||||
|
// }
|
||||||
|
|
||||||
|
// #[test]
|
||||||
|
// fn save_errors() {
|
||||||
|
// // serde_json will raise an error as it has certain requirements on keys.
|
||||||
|
// let mut object = HashMap::<Result<(), ()>, String>::new();
|
||||||
|
// object.insert(Ok(()), String::from("string"));
|
||||||
|
// let serde_err = serde_json::to_string(&object);
|
||||||
|
// assert!(serde_err.is_err());
|
||||||
|
|
||||||
|
// let serde_err: SaveError = serde_err.unwrap_err().into();
|
||||||
|
// assert!(!serde_err.to_string().is_empty());
|
||||||
|
// assert!(!format!("{:?}", serde_err).is_empty());
|
||||||
|
// }
|
||||||
|
// }
|
Loading…
x
Reference in New Issue
Block a user