From e89353268071d7c5acbf5b5bd8170ac929cea51d Mon Sep 17 00:00:00 2001 From: Steven Hildreth Date: Mon, 19 Nov 2018 17:51:58 -0600 Subject: [PATCH] Work on Subsonic layer using JamStash for testing. --- RoadieApi/Controllers/SubsonicController.cs | 68 +++++- RoadieApi/Services/ISubsonicService.cs | 17 +- RoadieApi/Services/SubsonicService.cs | 217 +++++++++++++++++- RoadieApi/Startup.cs | 7 +- .../Models/ThirdPartyApi/Subsonic/Request.cs | 164 +++++++++++++ 5 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 RoadieLibrary/Models/ThirdPartyApi/Subsonic/Request.cs diff --git a/RoadieApi/Controllers/SubsonicController.cs b/RoadieApi/Controllers/SubsonicController.cs index 40f0828..927db64 100644 --- a/RoadieApi/Controllers/SubsonicController.cs +++ b/RoadieApi/Controllers/SubsonicController.cs @@ -1,18 +1,20 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Newtonsoft.Json; using Roadie.Api.Services; using Roadie.Library.Caching; using Roadie.Library.Identity; +using Roadie.Library.Models.ThirdPartyApi.Subsonic; +using System.Threading.Tasks; namespace Roadie.Api.Controllers { - [Produces("application/json")] - [Route("subsonic")] + //[Produces("application/json")] + [Route("subsonic/rest")] [ApiController] - [Authorize] + //[Authorize] public class SubsonicController : EntityControllerBase { private ISubsonicService SubsonicService { get; } @@ -23,5 +25,61 @@ namespace Roadie.Api.Controllers this._logger = logger.CreateLogger("RoadieApi.Controllers.SubsonicController"); this.SubsonicService = subsonicService; } + + [HttpGet("getIndexes.view")] + [ProducesResponseType(200)] + public async Task GetIndexes([FromQuery]Request request, string musicFolderId = null, long? ifModifiedSince = null) + { + var result = await this.SubsonicService.GetIndexes(request, musicFolderId, ifModifiedSince); + return this.BuildResponse(request, result.Data, "indexes"); + } + + [HttpGet("getMusicFolders.view")] + [ProducesResponseType(200)] + public async Task GetMusicFolders([FromQuery]Request request) + { + var result = await this.SubsonicService.GetMusicFolders(request); + return this.BuildResponse(request, result.Data, "musicFolders"); + } + + [HttpGet("getPlaylists.view")] + [ProducesResponseType(200)] + public async Task GetPlaylists([FromQuery]Request request, string username) + { + var result = await this.SubsonicService.GetPlaylists(request, null, username); + return this.BuildResponse(request, result.Data, "playlists"); + } + + [HttpGet("ping.view")] + [HttpPost("ping.view")] + [ProducesResponseType(200)] + public IActionResult Ping([FromQuery]Request request) + { + var result = this.SubsonicService.Ping(request); + return this.BuildResponse(request, result.Data); + } + + #region Response Builder Methods + + private string BuildJsonResult(Response response, string responseType) + { + if (responseType == null) + { + return "{ \"subsonic-response\": { \"status\":\"" + response.status.ToString() + "\", \"version\": \"" + response.version + "\" }}"; + } + return "{ \"subsonic-response\": { \"status\":\"" + response.status.ToString() + "\", \"version\": \"" + response.version + "\", \"" + responseType + "\":" + JsonConvert.SerializeObject(response.Item) + "}}"; + } + + private IActionResult BuildResponse(Request request, Response response = null, string reponseType = null) + { + if (request.IsJSONRequest) + { + this.Response.ContentType = "application/json"; + return Content(this.BuildJsonResult(response, reponseType)); + } + return Ok(response); + } + + #endregion Response Builder Methods } } \ No newline at end of file diff --git a/RoadieApi/Services/ISubsonicService.cs b/RoadieApi/Services/ISubsonicService.cs index 5ddfe89..2a9685a 100644 --- a/RoadieApi/Services/ISubsonicService.cs +++ b/RoadieApi/Services/ISubsonicService.cs @@ -1,6 +1,21 @@ -namespace Roadie.Api.Services +using Roadie.Library; +using Roadie.Library.Models.ThirdPartyApi.Subsonic; +using System.Threading.Tasks; + +namespace Roadie.Api.Services { public interface ISubsonicService { + Task> GetGenres(Request request); + + Task> GetIndexes(Request request, string musicFolderId = null, long? ifModifiedSince = null); + + Task> GetMusicFolders(Request request); + + Task> GetPlaylists(Request request, Roadie.Library.Models.Users.User roadieUser, string filterToUserName); + + Task> GetPodcasts(Request request); + + OperationResult Ping(Request request); } } \ No newline at end of file diff --git a/RoadieApi/Services/SubsonicService.cs b/RoadieApi/Services/SubsonicService.cs index aa560c8..4951099 100644 --- a/RoadieApi/Services/SubsonicService.cs +++ b/RoadieApi/Services/SubsonicService.cs @@ -1,8 +1,25 @@ -using Microsoft.Extensions.Logging; +using Mapster; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Roadie.Library; using Roadie.Library.Caching; using Roadie.Library.Configuration; using Roadie.Library.Encoding; +using Roadie.Library.Enums; +using Roadie.Library.Extensions; +using Roadie.Library.Models; +using Roadie.Library.Models.Pagination; +using Roadie.Library.Models.Releases; +using Roadie.Library.Models.Statistics; +using subsonic = Roadie.Library.Models.ThirdPartyApi.Subsonic; +using Roadie.Library.Models.Users; using Roadie.Library.Utility; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Linq.Dynamic.Core; +using System.Threading.Tasks; using data = Roadie.Library.Data; namespace Roadie.Api.Services @@ -15,6 +32,10 @@ namespace Roadie.Api.Services /// public class SubsonicService : ServiceBase, ISubsonicService { + public const string ArtistIdIdentifier = "A:"; + public const string CollectionIdentifier = "C:"; + public const string SubsonicVersion = "1.16.1"; + public SubsonicService(IRoadieSettings configuration, IHttpEncoder httpEncoder, IHttpContext httpContext, @@ -26,5 +47,199 @@ namespace Roadie.Api.Services : base(configuration, httpEncoder, context, cacheManager, logger, httpContext) { } + + public OperationResult Ping(subsonic.Request request) + { + return new OperationResult + { + IsSuccess = true, + Data = new subsonic.Response + { + version = SubsonicService.SubsonicVersion, + status = subsonic.ResponseStatus.ok + } + }; + } + + /// + /// Returns all configured top-level music folders. Takes no extra parameters. + /// + public async Task> GetMusicFolders(subsonic.Request request) + { + return new OperationResult + { + IsSuccess = true, + Data = new subsonic.Response + { + version = SubsonicService.SubsonicVersion, + status = subsonic.ResponseStatus.ok, + ItemElementName = subsonic.ItemChoiceType.musicFolders, + Item = new subsonic.MusicFolders + { + musicFolder = this.MusicFolders().ToArray() + } + } + }; + } + + public List MusicFolders() + { + return new List + { + new subsonic.MusicFolder { id = 1, name = "Collections"}, + new subsonic.MusicFolder { id = 2, name = "Music"} + }; + } + + public subsonic.MusicFolder CollectionMusicFolder() + { + return this.MusicFolders().First(x => x.id == 1); + } + + /// + /// Returns an indexed structure of all artists. + /// + /// Query from application. + /// If specified, only return artists in the music folder with the given ID. + /// If specified, only return a result if the artist collection has changed since the given time (in milliseconds since 1 Jan 1970). + public async Task> GetIndexes(subsonic.Request request, string musicFolderId = null, long? ifModifiedSince = null) + { + + var modifiedSinceFilter = ifModifiedSince.HasValue ? (DateTime?)ifModifiedSince.Value.FromUnixTime() : null; + subsonic.MusicFolder musicFolderFilter = string.IsNullOrEmpty(musicFolderId) ? null : this.MusicFolders().FirstOrDefault(x => x.id == SafeParser.ToNumber(musicFolderId)); + var indexes = new List(); + + if (musicFolderFilter == this.CollectionMusicFolder()) + { + // Collections for Music Folders by Alpha First + foreach (var collectionFirstLetter in (from c in this.DbContext.Collections + let first = c.Name.Substring(0, 1) + orderby first + select first).Distinct().ToArray()) + { + indexes.Add(new subsonic.Index + { + name = collectionFirstLetter, + artist = (from c in this.DbContext.Collections + where c.Name.Substring(0, 1) == collectionFirstLetter + where modifiedSinceFilter == null || c.LastUpdated >= modifiedSinceFilter + orderby c.SortName, c.Name + select new subsonic.Artist + { + id = SubsonicService.CollectionIdentifier + c.RoadieId.ToString(), + name = c.Name, + artistImageUrl = this.MakeCollectionThumbnailImage(c.RoadieId).Url, + averageRating = 0, + userRating = 0 + }).ToArray() + }); + } + } + else + { + // Indexes for Artists alphabetically + foreach (var artistFirstLetter in (from c in this.DbContext.Artists + let first = c.Name.Substring(0, 1) + orderby first + select first).Distinct().ToArray()) + { + indexes.Add(new subsonic.Index + { + name = artistFirstLetter, + artist = (from c in this.DbContext.Artists + where c.Name.Substring(0, 1) == artistFirstLetter + where modifiedSinceFilter == null || c.LastUpdated >= modifiedSinceFilter + orderby c.SortName, c.Name + select new subsonic.Artist + { + id = SubsonicService.ArtistIdIdentifier + c.RoadieId.ToString(), + name = c.Name, + artistImageUrl = this.MakeArtistThumbnailImage(c.RoadieId).Url, + averageRating = 0, + userRating = 0 + }).ToArray() + }); + } + } + return new OperationResult + { + IsSuccess = true, + Data = new subsonic.Response + { + version = SubsonicService.SubsonicVersion, + status = subsonic.ResponseStatus.ok, + ItemElementName = subsonic.ItemChoiceType.indexes, + Item = new subsonic.Indexes + { + index = indexes.ToArray() + } + } + }; + } + + public async Task> GetPlaylists(subsonic.Request request, User roadieUser, string filterToUserName) + { + var playlists = (from playlist in this.DbContext.Playlists + join u in this.DbContext.Users on playlist.UserId equals u.Id + let trackCount = (from pl in this.DbContext.PlaylistTracks + where pl.PlayListId == playlist.Id + select pl.Id).Count() + let playListDuration = (from pl in this.DbContext.PlaylistTracks + join t in this.DbContext.Tracks on pl.TrackId equals t.Id + where pl.PlayListId == playlist.Id + select t.Duration).Sum() + where (playlist.IsPublic) || (roadieUser != null && playlist.UserId == roadieUser.Id) + select new subsonic.Playlist + { + id = playlist.RoadieId.ToString(), + name = playlist.Name, + comment = playlist.Description, + owner = u.Username, + songCount = trackCount, + duration = playListDuration ?? 0, + created = playlist.CreatedDate, + changed = playlist.LastUpdated ?? playlist.CreatedDate, + coverArt = this.MakePlaylistThumbnailImage(playlist.RoadieId).Url, + @public = playlist.IsPublic, + publicSpecified = playlist.IsPublic + } + ); + + return new OperationResult + { + IsSuccess = true, + Data = new subsonic.Response + { + version = SubsonicService.SubsonicVersion, + status = subsonic.ResponseStatus.ok, + ItemElementName = subsonic.ItemChoiceType.playlists, + Item = new subsonic.Playlists + { + playlist = playlists.ToArray() + } + } + }; + } + + public async Task> GetGenres(subsonic.Request request) + { + throw new NotImplementedException(); + } + + public async Task> GetPodcasts(subsonic.Request request) + { + return new OperationResult + { + IsSuccess = true, + Data = new subsonic.Response + { + version = SubsonicService.SubsonicVersion, + status = subsonic.ResponseStatus.ok, + ItemElementName = subsonic.ItemChoiceType.podcasts, + Item = new object[0] + } + }; + } + } } \ No newline at end of file diff --git a/RoadieApi/Startup.cs b/RoadieApi/Startup.cs index dea864b..8eb06fc 100644 --- a/RoadieApi/Startup.cs +++ b/RoadieApi/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.EntityFrameworkCore; @@ -181,12 +182,16 @@ namespace Roadie.Api services.AddSignalR(); - services.AddMvc() + services.AddMvc(options => + { + options.RespectBrowserAcceptHeader = true; // false by default + }) .AddJsonOptions(options => { options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }) + .AddXmlSerializerFormatters() .SetCompatibilityVersion(CompatibilityVersion.Version_2_1); services.AddHttpContextAccessor(); diff --git a/RoadieLibrary/Models/ThirdPartyApi/Subsonic/Request.cs b/RoadieLibrary/Models/ThirdPartyApi/Subsonic/Request.cs new file mode 100644 index 0000000..5476c80 --- /dev/null +++ b/RoadieLibrary/Models/ThirdPartyApi/Subsonic/Request.cs @@ -0,0 +1,164 @@ +using Roadie.Library.Extensions; +using System; + +namespace Roadie.Library.Models.ThirdPartyApi.Subsonic +{ + [Serializable] + public class Request + { + /// + /// A unique string identifying the client application. + /// + public string c { get; set; } + + /// + /// + /// + public string callback { get; set; } + + /// + /// Request data to be returned in this format. Supported values are "xml", "json" (since 1.4.0) and "jsonp" (since 1.6.0). If using jsonp, specify name of javascript callback function using a callback parameter. + /// + public string f { get; set; } + + public bool IsCallbackSet + { + get + { + return !string.IsNullOrEmpty(this.callback); + } + } + + + public bool IsJSONRequest + { + get + { + if (string.IsNullOrEmpty(this.f)) + { + return true; + } + return this.f.ToLower().StartsWith("j"); + } + } + + /// + /// The password, either in clear text or hex-encoded with a "enc:" prefix. Since 1.13.0 this should only be used for testing purposes. + /// + public string p { get; set; } + + public string Password + { + get + { + if (string.IsNullOrEmpty(this.p)) + { + return null; + } + if (this.p.StartsWith("enc:")) + { + return this.p.ToLower().Replace("enc:", "").FromHexString(); + } + return this.p; + } + } + + /// + /// A random string ("salt") used as input for computing the password hash. See below for details. + /// + public string s { get; set; } + + /// + /// The authentication token computed as md5(password + salt). See below for details + /// + public string t { get; set; } + + /// + /// The username + /// + public string u { get; set; } + + /// + /// The protocol version implemented by the client, i.e., the version of the subsonic-rest-api.xsd schema used (see below). + /// + public string v { get; set; } + + //public user CheckPasswordGetUser(ICacheManager cacheManager, RoadieDbContext context) + //{ + // user user = null; + // if (string.IsNullOrEmpty(this.UsernameValue)) + // { + // return null; + // } + // try + // { + // var cacheKey = string.Format("urn:user:byusername:{0}", this.UsernameValue.ToLower()); + // var resultInCache = cacheManager.Get(cacheKey); + // if (resultInCache == null) + // { + // user = context.users.FirstOrDefault(x => x.username.Equals(this.UsernameValue, StringComparison.OrdinalIgnoreCase)); + // var claims = new List + // { + // new Claim(Library.Authentication.ClaimTypes.UserId, user.id.ToString()).ToString() + // }; + // var sql = @"select ur.name FROM `userrole` ur LEFT JOIN usersInRoles uir on ur.id = uir.userRoleId where uir.userId = " + user.id + ";"; + // var userRoles = context.Database.SqlQuery(sql).ToList(); + // if (userRoles != null && userRoles.Any()) + // { + // foreach (var userRole in userRoles) + // { + // claims.Add(new Claim(Library.Authentication.ClaimTypes.UserRole, userRole).ToString()); + // } + // } + // user.ClaimsValue = claims; + // cacheManager.Add(cacheKey, user); + // } + // else + // { + // user = resultInCache; + // } + // if (user == null) + // { + // return null; + // } + // var password = this.Password; + // var wasAuthenticatedAgainstPassword = false; + // if (!string.IsNullOrEmpty(this.s)) + // { + // var token = ModuleBase.MD5Hash((user.apiToken ?? user.email) + this.s); + // if (!token.Equals(this.t, StringComparison.OrdinalIgnoreCase)) + // { + // user = null; + // } + // else + // { + // wasAuthenticatedAgainstPassword = true; + // } + // } + // else + // { + // if (user != null && !BCrypt.Net.BCrypt.Verify(password, user.password)) + // { + // user = null; + // } + // else + // { + // wasAuthenticatedAgainstPassword = true; + // } + // } + // if (wasAuthenticatedAgainstPassword) + // { + // // Since API dont update LastLogin which likely invalidates any browser logins + // user.lastApiAccess = DateTime.UtcNow; + // context.SaveChanges(); + // } + // return user; + // } + // catch (Exception ex) + // { + // Trace.WriteLine("Error CheckPassword [" + ex.Serialize() + "]"); + // } + // return null; + //} + } +} \ No newline at end of file