Subsonic API work

This commit is contained in:
Steven Hildreth 2018-11-20 08:36:07 -06:00
parent aaba240a53
commit f34f428143
13 changed files with 348 additions and 51 deletions

View file

@ -1,11 +1,16 @@
using Mapster;
using Microsoft.AspNet.OData;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Roadie.Api.Services;
using Roadie.Library.Caching;
using Roadie.Library.Configuration;
using Roadie.Library.Identity;
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using models = Roadie.Library.Models.Users;
@ -50,5 +55,43 @@ namespace Roadie.Api.Controllers
result.IsEditor = User.IsInRole("Editor");
return result;
}
protected async Task<FileStreamResult> StreamTrack(Guid id, ITrackService trackService, IPlayActivityService playActivityService)
{
var user = await this.CurrentUserModel();
var track = await trackService.ById(user, id, null);
if (track == null || track.IsNotFoundResult)
{
Response.StatusCode = (int)HttpStatusCode.NotFound;
}
var info = await trackService.TrackStreamInfo(id,
Services.TrackService.DetermineByteStartFromHeaders(this.Request.Headers),
Services.TrackService.DetermineByteEndFromHeaders(this.Request.Headers, track.Data.FileSize));
if (!info.IsSuccess)
{
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
Response.Headers.Add("Content-Disposition", info.Data.ContentDisposition);
Response.Headers.Add("X-Content-Duration", info.Data.ContentDuration);
if (!info.Data.IsFullRequest)
{
Response.Headers.Add("Accept-Ranges", info.Data.AcceptRanges);
Response.Headers.Add("Content-Range", info.Data.ContentRange);
}
Response.Headers.Add("Content-Length", info.Data.ContentLength);
Response.ContentType = info.Data.ContentType;
Response.StatusCode = info.Data.IsFullRequest ? (int)HttpStatusCode.OK : (int)HttpStatusCode.PartialContent;
Response.Headers.Add("Last-Modified", info.Data.LastModified);
Response.Headers.Add("ETag", info.Data.Etag);
Response.Headers.Add("Cache-Control", info.Data.CacheControl);
Response.Headers.Add("Expires", info.Data.Expires);
var stream = new MemoryStream(info.Data.Bytes);
var playListUser = await playActivityService.CreatePlayActivity(user, info.Data);
this._logger.LogInformation($"StreamTrack PlayActivity `{ playListUser?.ToString() }`, StreamInfo `{ info.Data.ToString() }`");
return new FileStreamResult(stream, info.Data.ContentType)
{
FileDownloadName = info.Data.FileName
};
}
}
}

View file

@ -68,40 +68,7 @@ namespace Roadie.Api.Controllers
[HttpGet("track/{id}.{mp3?}")]
public async Task<FileStreamResult> StreamTrack(Guid id)
{
var user = await this.CurrentUserModel();
var track = await this.TrackService.ById(user, id, null);
if (track == null || track.IsNotFoundResult)
{
Response.StatusCode = (int)HttpStatusCode.NotFound;
}
var info = await this.TrackService.TrackStreamInfo(id,
Services.TrackService.DetermineByteStartFromHeaders(this.Request.Headers),
Services.TrackService.DetermineByteEndFromHeaders(this.Request.Headers, track.Data.FileSize));
if (!info.IsSuccess)
{
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
Response.Headers.Add("Content-Disposition", info.Data.ContentDisposition);
Response.Headers.Add("X-Content-Duration", info.Data.ContentDuration);
if (!info.Data.IsFullRequest)
{
Response.Headers.Add("Accept-Ranges", info.Data.AcceptRanges);
Response.Headers.Add("Content-Range", info.Data.ContentRange);
}
Response.Headers.Add("Content-Length", info.Data.ContentLength);
Response.ContentType = info.Data.ContentType;
Response.StatusCode = info.Data.IsFullRequest ? (int)HttpStatusCode.OK : (int)HttpStatusCode.PartialContent;
Response.Headers.Add("Last-Modified", info.Data.LastModified);
Response.Headers.Add("ETag", info.Data.Etag);
Response.Headers.Add("Cache-Control", info.Data.CacheControl);
Response.Headers.Add("Expires", info.Data.Expires);
var stream = new MemoryStream(info.Data.Bytes);
var playListUser = await this.PlayActivityService.CreatePlayActivity(user, info.Data);
this._logger.LogInformation($"StreamTrack PlayActivity `{ playListUser?.ToString() }`, StreamInfo `{ info.Data.ToString() }`");
return new FileStreamResult(stream, info.Data.ContentType)
{
FileDownloadName = info.Data.FileName
};
return await base.StreamTrack(id, this.TrackService, this.PlayActivityService);
}
}
}

View file

@ -19,12 +19,16 @@ namespace Roadie.Api.Controllers
public class SubsonicController : EntityControllerBase
{
private ISubsonicService SubsonicService { get; }
private IPlayActivityService PlayActivityService { get; }
private ITrackService TrackService { get; }
public SubsonicController(ISubsonicService subsonicService, ILoggerFactory logger, ICacheManager cacheManager, IConfiguration configuration, UserManager<ApplicationUser> userManager)
public SubsonicController(ISubsonicService subsonicService, ITrackService trackService, IPlayActivityService playActivityService, ILoggerFactory logger, ICacheManager cacheManager, IConfiguration configuration, UserManager<ApplicationUser> userManager)
: base(cacheManager, configuration, userManager)
{
this._logger = logger.CreateLogger("RoadieApi.Controllers.SubsonicController");
this.SubsonicService = subsonicService;
this.TrackService = trackService;
this.PlayActivityService = playActivityService;
}
[HttpGet("getIndexes.view")]
@ -76,6 +80,27 @@ namespace Roadie.Api.Controllers
return this.BuildResponse(request, result.Data, "podcasts");
}
[HttpGet("getAlbumList.view")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetAlbumList([FromQuery]Request request)
{
var result = await this.SubsonicService.GetAlbumList(request, null);
return this.BuildResponse(request, result.Data, "albumList");
}
[HttpGet("stream.view")]
[HttpPost("stream.view")]
[ProducesResponseType(200)]
public async Task<FileStreamResult> StreamTrack([FromQuery]Request request)
{
var trackId = request.TrackId;
if (trackId == null)
{
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
}
return await base.StreamTrack(trackId.Value, this.TrackService, this.PlayActivityService);
}
[HttpGet("getCoverArt.view")]
[ProducesResponseType(200)]
public async Task<IActionResult> GetCoverArt([FromQuery]Request request, int? size)
@ -106,6 +131,9 @@ namespace Roadie.Api.Controllers
return this.BuildResponse(request, result.Data);
}
#region Response Builder Methods
private string BuildJsonResult(Response response, string responseType)

View file

@ -18,6 +18,8 @@ namespace Roadie.Api.Services
Task<OperationResult<Response>> GetMusicDirectory(Request request, Roadie.Library.Models.Users.User roadieUser, string id);
Task<OperationResult<Response>> GetAlbumList(Request request, Roadie.Library.Models.Users.User roadieUser);
Task<FileOperationResult<Roadie.Library.Models.Image>> GetCoverArt(Request request, int? size);
OperationResult<Response> Ping(Request request);

View file

@ -110,16 +110,16 @@ namespace Roadie.Api.Services
var sw = new Stopwatch();
sw.Start();
if (!string.IsNullOrEmpty(request.Sort))
{
request.Sort = request.Sort.Replace("createdDate", "createdDateTime");
request.Sort = request.Sort.Replace("lastUpdated", "lastUpdatedDateTime");
request.Sort = request.Sort.Replace("ReleaseDate", "ReleaseDateDateTime");
request.Sort = request.Sort.Replace("releaseYear", "ReleaseDateDateTime");
}
var result = (from r in this.DbContext.Releases.Include("Artist")
join a in this.DbContext.Artists on r.ArtistId equals a.Id
let lastPlayed = (from ut in this.DbContext.UserTracks
join t in this.DbContext.Tracks on ut.TrackId equals t.Id
join rm in this.DbContext.ReleaseMedias on t.ReleaseMediaId equals rm.Id
join rl in this.DbContext.Releases on rm.ReleaseId equals rl.Id
where rl.Id == r.Id
orderby ut.LastPlayed descending
select ut.LastPlayed
).FirstOrDefault()
where (request.FilterMinimumRating == null || r.Rating >= request.FilterMinimumRating.Value)
where (request.FilterToArtistId == null || r.Artist.RoadieId == request.FilterToArtistId)
where (request.FilterValue == "" || (r.Title.Contains(request.FilterValue) || r.AlternateNames.Contains(request.FilterValue)))
@ -146,6 +146,7 @@ namespace Roadie.Api.Services
TrackCount = r.TrackCount,
CreatedDate = r.CreatedDate,
LastUpdated = r.LastUpdated,
LastPlayed = lastPlayed != null ? lastPlayed : null,
TrackPlayedCount = (from ut in this.DbContext.UserTracks
join t in this.DbContext.Tracks on ut.TrackId equals t.Id
join rm in this.DbContext.ReleaseMedias on t.ReleaseMediaId equals rm.Id
@ -161,7 +162,8 @@ namespace Roadie.Api.Services
if (doRandomize ?? false)
{
request.Limit = request.LimitValue > roadieUser.RandomReleaseLimit ? roadieUser.RandomReleaseLimit : request.LimitValue;
var randomLimit = roadieUser?.RandomReleaseLimit ?? 100;
request.Limit = request.LimitValue > randomLimit ? randomLimit : request.LimitValue;
rows = result.OrderBy(x => Guid.NewGuid()).Skip(request.SkipValue).Take(request.LimitValue).ToArray();
}
else
@ -175,6 +177,14 @@ namespace Roadie.Api.Services
{
sortBy = string.IsNullOrEmpty(request.Sort) ? request.OrderValue(new Dictionary<string, string> { { "Release.Text", "ASC" } }) : request.OrderValue(null);
}
if(request.FilterRatedOnly)
{
result = result.Where(x => x.Rating.HasValue);
}
if(request.FilterMinimumRating.HasValue)
{
result = result.Where(x => x.Rating.HasValue && x.Rating.Value >= request.FilterMinimumRating.Value);
}
rows = result.OrderBy(sortBy).Skip(request.SkipValue).Take(request.LimitValue).ToArray();
}
if (rows.Any() && roadieUser != null)
@ -184,11 +194,14 @@ namespace Roadie.Api.Services
var row = rows.FirstOrDefault(x => x.DatabaseId == userReleaseRatings.ReleaseId);
if (row != null)
{
var isDisliked = userReleaseRatings.IsDisliked ?? false;
var isFavorite = userReleaseRatings.IsFavorite ?? false;
row.UserRating = new UserRelease
{
IsDisliked = userReleaseRatings.IsDisliked ?? false,
IsFavorite = userReleaseRatings.IsFavorite ?? false,
Rating = userReleaseRatings.Rating
IsDisliked = isDisliked,
IsFavorite = isFavorite,
Rating = userReleaseRatings.Rating,
RatedDate = isDisliked || isFavorite ? (DateTime?)(userReleaseRatings.LastUpdated ?? userReleaseRatings.CreatedDate) : null
};
}
}

View file

@ -159,6 +159,21 @@ namespace Roadie.Api.Services
}, data.Track.CacheRegionUrn(id));
}
protected ApplicationUser GetUser(string username)
{
if (string.IsNullOrEmpty(username))
{
return null;
}
var userByUsername = this.CacheManager.Get(ApplicationUser.CacheUrnByUsername(username), () =>
{
return this.DbContext.Users
.FirstOrDefault(x => x.UserName == username);
}, null);
return this.GetUser(userByUsername?.RoadieId);
}
protected ApplicationUser GetUser(Guid? id)
{
if(!id.HasValue)

View file

@ -5,6 +5,7 @@ using Roadie.Library.Configuration;
using Roadie.Library.Encoding;
using Roadie.Library.Extensions;
using Roadie.Library.Imaging;
using Roadie.Library.Models.Releases;
using Roadie.Library.Models.Users;
using Roadie.Library.Utility;
using System;
@ -28,6 +29,8 @@ namespace Roadie.Api.Services
{
public const string SubsonicVersion = "1.16.1";
private IReleaseService ReleaseService { get; }
public SubsonicService(IRoadieSettings configuration,
IHttpEncoder httpEncoder,
IHttpContext httpContext,
@ -35,9 +38,11 @@ namespace Roadie.Api.Services
ICacheManager cacheManager,
ILogger<SubsonicService> logger,
ICollectionService collectionService,
IPlaylistService playlistService)
IPlaylistService playlistService,
IReleaseService releaseService)
: base(configuration, httpEncoder, context, cacheManager, logger, httpContext)
{
this.ReleaseService = releaseService;
}
public OperationResult<subsonic.Response> Ping(subsonic.Request request)
@ -348,6 +353,7 @@ namespace Roadie.Api.Services
genre = genre != null ? genre.Genre.Name : null,
coverArt = subsonic.Request.ReleaseIdIdentifier + r.RoadieId.ToString(),
created = collection.CreatedDate,
path = $"{ r.Artist.Name}/{ r.Title}/",
playCount = playCount ?? 0
}).ToArray();
@ -480,12 +486,12 @@ namespace Roadie.Api.Services
}
else if(!string.IsNullOrEmpty(request.u))
{
var userByUsername = this.DbContext.Users.FirstOrDefault(x => x.UserName == request.u);
if(userByUsername == null)
var user = this.GetUser(request.u);
if(user == null)
{
return new FileOperationResult<Roadie.Library.Models.Image>(true, $"Invalid Username [{ request.u}]");
}
result.Data.Bytes = userByUsername.Avatar;
result.Data.Bytes = user.Avatar;
}
if (size.HasValue && result.Data.Bytes != null)
@ -508,5 +514,82 @@ namespace Roadie.Api.Services
};
}
public async Task<OperationResult<subsonic.Response>> GetAlbumList(subsonic.Request request, User roadieUser)
{
var releaseResult = new Library.Models.Pagination.PagedResult<ReleaseList>();
switch (request.Type)
{
case subsonic.ListType.Random:
releaseResult = await this.ReleaseService.List(roadieUser, request.PagedRequest, true);
break;
case subsonic.ListType.Highest:
case subsonic.ListType.Recent:
case subsonic.ListType.Newest:
case subsonic.ListType.Frequent:
releaseResult = await this.ReleaseService.List(roadieUser, request.PagedRequest);
break;
break;
case subsonic.ListType.AlphabeticalByName:
break;
case subsonic.ListType.AlphabeticalByArtist:
break;
case subsonic.ListType.Starred:
releaseResult = await this.ReleaseService.List(roadieUser, request.PagedRequest);
break;
case subsonic.ListType.ByYear:
break;
case subsonic.ListType.ByGenre:
break;
default:
return new OperationResult<subsonic.Response>($"Unknown Album List Type [{ request.Type}]");
}
if(!releaseResult.IsSuccess)
{
return new OperationResult<subsonic.Response>(releaseResult.Message);
}
var albums = releaseResult.Rows.Select(r => new subsonic.Child
{
id = subsonic.Request.ReleaseIdIdentifier + r.Id.ToString(),
parent = subsonic.Request.ArtistIdIdentifier + r.Artist.Value,
isDir = true,
title = r.Release.Text,
album = r.Release.Text,
albumId = subsonic.Request.ReleaseIdIdentifier + r.Id.ToString(),
artist = r.Artist.Text,
year = SafeParser.ToNumber<int>(r.ReleaseYear),
// genre = r.Genre.Text,
coverArt = subsonic.Request.ReleaseIdIdentifier + r.Id.ToString(),
averageRating = r.Rating ?? 0,
averageRatingSpecified = true,
created = r.CreatedDate.Value,
createdSpecified = true,
path = $"{ r.Artist.Text}/{ r.Release.Text}/",
playCount = r.TrackPlayedCount ?? 0,
playCountSpecified = true,
starred = r.UserRating != null ? (r.UserRating.IsFavorite ? r.UserRating.RatedDate : null) : null,
userRating = r.UserRating != null ? r.UserRating.Rating ?? 0 : 0,
userRatingSpecified = r.UserRating != null && r.UserRating.Rating != null
}).ToArray();
return new OperationResult<subsonic.Response>
{
IsSuccess = true,
Data = new subsonic.Response
{
version = SubsonicService.SubsonicVersion,
status = subsonic.ResponseStatus.ok,
ItemElementName = subsonic.ItemChoiceType.albumList,
Item = new subsonic.AlbumList
{
album = albums
}
}
};
}
}
}

View file

@ -16,6 +16,11 @@ namespace Roadie.Library.Identity
return $"urn:user_by_id:{ Id }";
}
public static string CacheUrnByUsername(string Username)
{
return $"urn:user_by_username:{ Username }";
}
public string CacheRegion
{
get
@ -24,6 +29,14 @@ namespace Roadie.Library.Identity
}
}
public string CacheKeyByUsername
{
get
{
return ApplicationUser.CacheUrnByUsername(this.UserName);
}
}
public string CacheKey
{
get

View file

@ -89,6 +89,7 @@ namespace Roadie.Library.Models.Pagination
public Guid? FilterToArtistId { get; set; }
public int? FilterMinimumRating { get; set; }
public bool FilterRatedOnly { get; internal set; }
public PagedRequest()
{ }

View file

@ -44,5 +44,7 @@ namespace Roadie.Library.Models.Releases
public int? TrackPlayedCount { get; set; }
public UserRelease UserRating { get; set; }
public Statuses? Status { get; set; }
public DataToken Genre { get; set; }
public DateTime? LastPlayed { get; set; }
}
}

View file

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Roadie.Library.Models.ThirdPartyApi.Subsonic
{
public enum ListType : short
{
Unknown = 0,
Random,
Newest,
Highest,
Frequent,
Recent,
AlphabeticalByName,
AlphabeticalByArtist,
Starred,
ByYear,
ByGenre
}
}

View file

@ -7,6 +7,8 @@ namespace Roadie.Library.Models.ThirdPartyApi.Subsonic
[Serializable]
public class Request
{
public const int MaxPageSize = 500;
public const string ArtistIdIdentifier = "A:";
public const string CollectionIdentifier = "C:";
public const string ReleaseIdIdentifier = "R:";
@ -159,6 +161,112 @@ namespace Roadie.Library.Models.ThirdPartyApi.Subsonic
}
}
public Guid? TrackId
{
get
{
if (string.IsNullOrEmpty(this.id))
{
return null;
}
if (this.id.StartsWith(Request.TrackIdIdentifier))
{
return SafeParser.ToGuid(this.id.Replace(Request.TrackIdIdentifier, ""));
}
return null;
}
}
#region Paging and List Related
/// <summary>
/// The number of albums to return. Max 500.
/// </summary>
public int? Size { get; set; }
/// <summary>
/// The list offset. Useful if you for example want to page through the list of newest albums.
/// </summary>
public int? Offset { get; set; }
/// <summary>
/// The first year in the range. If fromYear > toYear a reverse chronological list is returned.
/// </summary>
public int? FromYear { get; set; }
/// <summary>
/// The last year in the range.
/// </summary>
public int? ToYear { get; set; }
/// <summary>
/// The name of the genre, e.g., "Rock".
/// </summary>
public string Genre { get; set; }
/// <summary>
/// Only return albums in the music folder with the given ID. See getMusicFolders.
/// </summary>
public int? MusicFolderId { get; set; }
public ListType Type { get; set; }
//var pagedRequest = new Library.Models.Pagination.PagedRequest
//{
//};
private Library.Models.Pagination.PagedRequest _pagedRequest;
public Library.Models.Pagination.PagedRequest PagedRequest
{
get
{
if(this._pagedRequest == null)
{
var limit = this.Size ?? Request.MaxPageSize;
var page = this.Offset > 0 ? (int)Math.Ceiling((decimal)this.Offset.Value / (decimal)limit) : 1;
var pagedRequest = new Pagination.PagedRequest();
switch (this.Type)
{
case ListType.Newest:
pagedRequest.Sort = "CreatedDate";
pagedRequest.Order = "DESC";
break;
case ListType.Highest:
pagedRequest.Sort = "Rating";
pagedRequest.Order = "DESC";
pagedRequest.FilterRatedOnly = true;
break;
case ListType.Frequent:
pagedRequest.Sort = "TrackPlayedCount";
pagedRequest.Order = "DESC";
break;
case ListType.Recent:
pagedRequest.Sort = "LastPlayed";
pagedRequest.Order = "DESC";
break;
case ListType.AlphabeticalByName:
break;
case ListType.AlphabeticalByArtist:
break;
case ListType.Starred:
pagedRequest.FilterRatedOnly = true;
break;
case ListType.ByGenre:
break;
default:
break;
}
pagedRequest.Limit = limit;
pagedRequest.Page = page;
this._pagedRequest = pagedRequest;
}
return this._pagedRequest;
}
}
#endregion
//public user CheckPasswordGetUser(ICacheManager<object> cacheManager, RoadieDbContext context)

View file

@ -8,5 +8,6 @@ namespace Roadie.Library.Models.Users
public bool IsDisliked { get; set; }
public bool IsFavorite { get; set; }
public short? Rating { get; set; }
public DateTime? RatedDate { get; set; }
}
}