542 lines
21 KiB
C#
542 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Net.Http;
|
|
using System.Threading.Tasks;
|
|
using Newtonsoft.Json;
|
|
|
|
/// <summary>
|
|
/// Classes and other definitions relating to the <see cref="Nightwave"/> Plaza API
|
|
/// </summary>
|
|
namespace libplaza {
|
|
#region API models
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaPlayback {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string artist;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string title;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string album;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int length;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int position;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string artwork;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string artwork_s;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int likes;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int hates;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaJson {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public bool maintenance;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int listeners;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public long updated_at;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
[JsonProperty]
|
|
public PlazaPlayback playback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaUser {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int id;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string username;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string email;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int role;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string created_at;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaVote {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string status;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int[] ratings;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaGetVote {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public int rate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaLogoutResult {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string status;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A transitional class used internally by <see cref="JsonConvert.DeserializeObject{T}(string)"/>
|
|
/// </summary>
|
|
public class PlazaLogonResult {
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string status;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string username;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string email;
|
|
|
|
/// <summary>
|
|
/// A representation of a JSON object attribute, used transitionally while processing a deserialized JSON payload
|
|
/// </summary>
|
|
public string token;
|
|
}
|
|
#endregion
|
|
#region Exceptions
|
|
/// <summary>
|
|
/// An <see cref="Exception"/> object indicating that the <see cref="Nightwave"/> API is under maintenance
|
|
/// </summary>
|
|
internal class PlazaInMaintenanceException : Exception { }
|
|
|
|
/// <summary>
|
|
/// An <see cref="Exception"/> object indicating that an error or exception occurred when communicating with the <see cref="Nightwave"/> API.
|
|
/// </summary>
|
|
internal class PlazaUnavailableException : Exception { }
|
|
#endregion
|
|
#region Type Definitions
|
|
/// <summary>
|
|
///Represents the current broadcast information returned by a <see cref="Nightwave"/> API object.
|
|
/// </summary>
|
|
public class Status {
|
|
/// <summary>
|
|
/// Indicates whether the <see cref="Nightwave"/> API is in maintenance mode
|
|
/// </summary>
|
|
public bool InMaintenance;
|
|
|
|
/// <summary>
|
|
/// Indicates whether an exception occurred while querying the <see cref="Nightwave"/> API
|
|
/// </summary>
|
|
public bool FaultOccurred;
|
|
|
|
/// <summary>
|
|
/// Contains the currently-playing track's title
|
|
/// </summary>
|
|
public string Title;
|
|
|
|
/// <summary>
|
|
/// Contains the currently-playing track's creating artist
|
|
/// </summary>
|
|
public string Artist;
|
|
|
|
/// <summary>
|
|
/// Contains the currently-playing track's album, if any
|
|
/// </summary>
|
|
public string Album;
|
|
|
|
/// <summary>
|
|
/// Contains a relative URI to the artwork for the currently-playing track
|
|
/// </summary>
|
|
public string ArtworkUri;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the elapsed seconds for the current track
|
|
/// </summary>
|
|
public int Elapsed;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the duration in seconds of the current track
|
|
/// </summary>
|
|
public int Duration;
|
|
|
|
/// <summary>
|
|
/// Contains a 64-bit integer value of the epoch timestamp when the broadcast information was updated
|
|
/// </summary>
|
|
public long Since;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the time difference between the local computer and the <see cref="Nightwave"/> API server
|
|
/// </summary>
|
|
public int ServerTimeOffset;
|
|
|
|
/// <summary>
|
|
/// Returns a dynamically-generated integer value of the elapsed seconds, taking time offsets and time since <see cref="Since"/>
|
|
/// </summary>
|
|
public int CalculatedElapsed =>
|
|
(int)(DateTimeOffset.Now.ToUnixTimeSeconds() - ServerTimeOffset - Since) + Elapsed;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the number of likes the currently-playing track has received
|
|
/// </summary>
|
|
public int Likes;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the number of dislikes the currently-playing track has received
|
|
/// </summary>
|
|
public int Dislikes;
|
|
|
|
/// <summary>
|
|
/// Contains an integer value of the number of people currently listening to the broadcast
|
|
/// </summary>
|
|
public int Listeners;
|
|
}
|
|
/// <summary>
|
|
/// Represents the user's disposition towards the currently-playing track.
|
|
/// </summary>
|
|
public enum Vote {
|
|
/// <summary>
|
|
/// To <c>Dislike</c> is to indicate the user's disposition towards this track is one of displeasure;
|
|
/// that the user does not appreciate hearing this track, perhaps even that the user wishes not to hear this track.
|
|
/// </summary>
|
|
Dislike = -1,
|
|
|
|
/// <summary>
|
|
/// To be of <c>Neutral</c> disposition is to indicate the user has no feelings towards this track,
|
|
/// or that the user does have feelings, but not strong enough to be worth expressing
|
|
/// </summary>
|
|
Neutral,
|
|
|
|
/// <summary>
|
|
/// To <c>Like</c> is to indicate the user's disposition towards this track is one of enjoyment or pleasure;
|
|
/// that the user appreciated this track and/or wishes to indicate such.
|
|
/// </summary>
|
|
Like,
|
|
|
|
/// <summary>
|
|
/// To <c>Favourite</c> is functionally equivalent to <see cref="Like"/>, with the additional effect of
|
|
/// adding the track to the user's favourites list.
|
|
/// </summary>
|
|
Favourite
|
|
}
|
|
#endregion
|
|
/// <summary>
|
|
/// <see cref="Nightwave"/> is the primary class by which the Nightwave Plaza API can be used.
|
|
/// </summary>
|
|
public class Nightwave : IDisposable {
|
|
/// <summary>
|
|
/// <see cref="MinRefreshTime"/> represents the minimum number of seconds that must pass before the cached <see cref="Status"/> may be invalidated
|
|
/// </summary>
|
|
public int MinRefreshTime;
|
|
private long tsLastRefresh=0;
|
|
private static HttpClient _hcl=new HttpClient();
|
|
private Status _bc;
|
|
|
|
/// <summary>
|
|
/// Creates a new <see cref="Nightwave"/> instance.
|
|
/// </summary>
|
|
/// <param name="baseuri">Specify an alternate URI for the API - useful if testing a dev server.</param>
|
|
/// <example>
|
|
/// To create a <see cref="Nightwave"/> API object targeting the production Nightwave Plaza API:
|
|
/// <code>
|
|
/// <see cref="Nightwave"/> nw = new <see cref="Nightwave"/>();
|
|
/// </code>
|
|
/// To create a <see cref="Nightwave"/> API object targeting a local server (eg., during development):
|
|
/// <code><see cref="Nightwave"/> nwd = new <see cref="Nightwave"/>("http://localhost:8000");</code>
|
|
/// </example>
|
|
public Nightwave(string baseuri = "https://api.plaza.one/") {
|
|
MinRefreshTime = 10;
|
|
_hcl.BaseAddress = new Uri(baseuri);
|
|
_hcl.Timeout = TimeSpan.FromSeconds(2);
|
|
_hcl.DefaultRequestHeaders.Accept.Clear();
|
|
_hcl.DefaultRequestHeaders.Accept.Add(
|
|
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <see cref="Nightwave"/> object deconstructor. Simply a wrapper to ensure that <see cref="Dispose"/> is called.
|
|
/// </summary>
|
|
~Nightwave() =>
|
|
Dispose();
|
|
|
|
/// <summary>
|
|
/// De-initialises the <see cref="Nightwave"/> API object.
|
|
/// <para>Currently, this is equivalent to synchronously logging out the current session.</para>
|
|
/// </summary>
|
|
public void Dispose() =>
|
|
Task.Run(async () => await Logout());
|
|
|
|
/// <summary>
|
|
/// Retrieves the current Nightwave Plaza radio broadcast information, if possible
|
|
/// </summary>
|
|
/// <example>
|
|
/// If Nightwave Plaza is broadcasting normally:
|
|
/// <code>
|
|
/// <see cref="Nightwave"/> n = new Nightwave();
|
|
/// <see cref="Status"/> s = await n.Status();
|
|
/// Console.WriteLine(s.Title);
|
|
/// </code>
|
|
/// The Console output would show "リサフランク420 / 現代のコンピュー", if that track were playing.
|
|
/// In the event that an HTTP request fails or Nightwave Plaza is in maintenance mode, the appropriate flag will be set
|
|
/// <code>
|
|
/// <see cref="Nightwave"/> n = new Nightwave();
|
|
/// <see cref="Status"/> s = await n.Status();
|
|
/// if (s.InMaintenance) {
|
|
/// Console.WriteLine("In maintenance!");
|
|
/// return;
|
|
/// } else if (s.FaultOccurred) {
|
|
/// Console.WriteLine("A fault occurred!");
|
|
/// return;
|
|
/// }
|
|
/// Console.WriteLine(s.Title);
|
|
/// </code>
|
|
/// The Console output would show "In maintenance!" or "A fault occurred!", depending on the conditions.
|
|
/// </example>
|
|
/// <remarks>
|
|
/// This method has inherent caching, and thus will not necessarily result in the <see cref="Status"/> object returned being fully fresh.
|
|
/// The <see cref="Status"/> object being returned may be a cached object UNLESS the <paramref name="Force"/> parameter is true
|
|
/// OR the <see cref="Status"/> object is older than the number of seconds stored by <see cref="MinRefreshTime"/>
|
|
/// OR <see cref="Status.CalculatedElapsed"/> is equal to or greater than <see cref="Status.Duration"/>.
|
|
/// </remarks>
|
|
/// <param name="Force">If true, the API will always be queried, and no caching will be performed.</param>
|
|
/// <returns>A populated <see cref="Status"/> object describing the current broadcast details.</returns>
|
|
public async Task<Status> Status(bool Force = false) {
|
|
//An API call will ONLY be made if Force is set, if the cached data is "stale",
|
|
// or if the currently-playing track's elapsed time is more than the track duration.
|
|
if(Force
|
|
|| (tsLastRefresh + MinRefreshTime < DateTimeOffset.Now.ToUnixTimeSeconds())
|
|
|| _bc.CalculatedElapsed < _bc.Duration) {
|
|
await Update();
|
|
tsLastRefresh = DateTimeOffset.Now.ToUnixTimeSeconds();
|
|
}
|
|
return _bc;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A basic wrapper around <see cref="GetNightwaveAsync"/> which provides basic error handling.
|
|
/// </summary>
|
|
/// <returns>If called with <c>await</c>, returns nothing. If called without, returns a <c>Task</c> object for the method call</returns>
|
|
public async Task Update() {
|
|
try {
|
|
Status _=await GetNightwaveAsync();
|
|
_.InMaintenance = false;
|
|
_.FaultOccurred = false;
|
|
_bc = _;
|
|
} catch(PlazaInMaintenanceException) {
|
|
_bc.InMaintenance = true;
|
|
_bc.FaultOccurred = false;
|
|
} catch(Exception) { //TODO make this more reliable?
|
|
_bc.FaultOccurred = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Communicates with the <see cref="Nightwave"/> API to retrieve the current broadcast information.
|
|
/// </summary>
|
|
/// <returns>Returns a <c>Status</c> object representing the current broadcast information.</returns>
|
|
/// <exception cref="PlazaUnavailableException">May be thrown in event of a non-success (HTTP/1.1 2XX) return code</exception>
|
|
/// <exception cref="PlazaInMaintenanceException">May be thrown in event that the API indicates it is in maintenance</exception>
|
|
public async Task<Status> GetNightwaveAsync() {
|
|
Status status;
|
|
HttpResponseMessage _r=await _hcl.GetAsync("/status");
|
|
if(!_r.IsSuccessStatusCode)
|
|
throw new PlazaUnavailableException();
|
|
PlazaJson j=JsonConvert.DeserializeObject<PlazaJson>(await _r.Content.ReadAsStringAsync());
|
|
if(j.maintenance)
|
|
throw new PlazaInMaintenanceException();
|
|
status = new Status {
|
|
Title = j.playback.title,
|
|
Album = j.playback.album,
|
|
Artist = j.playback.artist,
|
|
Elapsed = j.playback.position,
|
|
Duration = j.playback.length,
|
|
Since = j.updated_at,
|
|
Likes = j.playback.likes,
|
|
Dislikes = j.playback.hates,
|
|
Listeners = j.listeners,
|
|
ServerTimeOffset = (int)(DateTimeOffset.Now.ToUnixTimeSeconds() - _r.Headers.Date.Value.ToUnixTimeSeconds()),
|
|
ArtworkUri = $"https://plaza.one/{j.playback.artwork}"
|
|
};
|
|
return status;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches the user's <see cref="Vote"/> for the currently-playing track.
|
|
/// </summary>
|
|
/// <returns>A <see cref="Vote"/> object indicating the user's disposition towards the track. If the vote could not be returned, this will be <c>null</c></returns>
|
|
public async Task<Vote?> Vote() {
|
|
if(!await CheckSession())
|
|
return null;
|
|
HttpResponseMessage _r=await _hcl.GetAsync("/vote");
|
|
if(!_r.IsSuccessStatusCode)
|
|
return null;
|
|
PlazaGetVote r=JsonConvert.DeserializeObject<PlazaGetVote>(await _r.Content.ReadAsStringAsync());
|
|
return (Vote)r.rate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Casts the given <see cref="Vote"/> specified in <paramref name="v"/> as the user's vote on the current track.
|
|
/// </summary>
|
|
/// <param name="v">A <see cref="Vote"/> object indicating the user's disposition towards the current track</param>
|
|
/// <returns>A <c>bool</c>ean value indicating the success of the vote operation</returns>
|
|
public async Task<bool> Vote(Vote v) {
|
|
if(!await CheckSession())
|
|
return false;
|
|
HttpResponseMessage _r=await _hcl.PostAsync("/vote", new FormUrlEncodedContent(new[] {
|
|
new KeyValuePair<string, string>("rate", ((int)v).ToString())}));
|
|
if(!_r.IsSuccessStatusCode)
|
|
return false;
|
|
PlazaVote r=JsonConvert.DeserializeObject<PlazaVote>(await _r.Content.ReadAsStringAsync());
|
|
return r.status == "success";
|
|
}
|
|
|
|
/// <summary>
|
|
/// An overload method to <see cref="CheckSession"/> to first set the <c>X-Access-Token</c> header to <paramref name="str"/>
|
|
/// </summary>
|
|
/// <param name="str">A valid session token for the <see cref="Nightwave"/> API</param>
|
|
/// <returns>A <c>bool</c>ean value indicating whether the token given in <paramref name="str"/> was able to perform an authenticated API request</returns>
|
|
public async Task<bool> CheckSession(string str) {
|
|
_ = _hcl.DefaultRequestHeaders.Remove("X-Access-Token");
|
|
_hcl.DefaultRequestHeaders.Add("X-Access-Token", str);
|
|
return await CheckSession();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs a basic read-only API request to determine whether the session token, if present, is valid.
|
|
/// </summary>
|
|
/// <returns>A <c>bool</c>ean value indicating whether the request succeeded</returns>
|
|
public async Task<bool> CheckSession() =>
|
|
await GetUser() != null;
|
|
|
|
/// <summary>
|
|
/// Performs a basic read-only API request to fetch the currently-authenticated user's profile details.
|
|
/// </summary>
|
|
/// <returns>If successful, returns a <c>string</c> containing the authenticated user's username. If unsuccessful, will return null.</returns>
|
|
public async Task<string> GetUser() {
|
|
HttpResponseMessage _r=await _hcl.GetAsync("/user");
|
|
if(!_r.IsSuccessStatusCode)
|
|
return null;
|
|
PlazaUser r=JsonConvert.DeserializeObject<PlazaUser>(await _r.Content.ReadAsStringAsync());
|
|
return r.id <= 0 ? null : r.username;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs a login request to the <see cref="Nightwave"/> API with the given <paramref name="u">username</paramref> and <paramref name="p">password</paramref>.
|
|
/// </summary>
|
|
/// <param name="u">A <c>string</c> containing the user's username</param>
|
|
/// <param name="p">A <c>string</c> containing the user's password</param>
|
|
/// <returns>A <c>bool</c>ean value indicating whether the login attempt succeeded</returns>
|
|
/// <remarks>
|
|
/// Functionally, this method is a wrapper for <see cref="Login(string)"/>, performing the function of generating a Basic authentication string
|
|
/// </remarks>
|
|
public async Task<bool> Login(string u, string p) =>
|
|
await Login(Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{u}:{p}")));
|
|
|
|
/// <summary>
|
|
/// Performs a login request to the <see cref="Nightwave"/> API with the given HTTP Basic authentication <paramref name="str">string</paramref>.
|
|
/// </summary>
|
|
/// <param name="str">A valid HTTP Basic authentication string (<c>md5(username:password)</c>)</param>
|
|
/// <returns>A <c>bool</c>ean value indicating whether the login attempt succeeded</returns>
|
|
public async Task<bool> Login(string str) {
|
|
_hcl.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", str);
|
|
HttpResponseMessage _r=await _hcl.PostAsync("/user/login", null);
|
|
if(!_r.IsSuccessStatusCode)
|
|
return false;
|
|
PlazaLogonResult r=JsonConvert.DeserializeObject<PlazaLogonResult>(await _r.Content.ReadAsStringAsync());
|
|
if(r.status != "success")
|
|
return false;
|
|
_hcl.DefaultRequestHeaders.Add("X-Access-Token", r.token);
|
|
_ = _hcl.DefaultRequestHeaders.Remove("Authorization");
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs a logout request to the <see cref="Nightwave"/> API.
|
|
/// </summary>
|
|
/// <returns>If called with <c>await</c>, returns nothing. Otherwise, returns a <c>Task</c> object representing the method call</returns>
|
|
public async Task Logout() {
|
|
//Any API call errors are assumed to indicate an invalid session
|
|
// Since the goal of a logout function is to not have a valid session, this is a good thing.
|
|
if(await CheckSession())
|
|
_ = await _hcl.PostAsync("/user/logout", null);
|
|
_ = _hcl.DefaultRequestHeaders.Remove("Authorization");
|
|
_ = _hcl.DefaultRequestHeaders.Remove("X-Access-Token");
|
|
}
|
|
}
|
|
}
|