Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
[Headers("Accept: application/json")]
|
||||
public interface IPlexServerApi
|
||||
{
|
||||
[Get("/library/sections")]
|
||||
public Task<PlexMediaContainerResponse<PlexLibraryResponse>> GetLibraries(
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
[Headers("Accept: application/json")]
|
||||
public interface IPlexTvApi
|
||||
{
|
||||
[Post("/pins")]
|
||||
Task<PlexPinResponse> StartPinFlow(
|
||||
[Query] [AliasAs("X-Plex-Product")]
|
||||
string product,
|
||||
[Query] [AliasAs("X-Plex-Client-Identifier")]
|
||||
string clientIdentifier,
|
||||
[Query]
|
||||
bool strong = true);
|
||||
|
||||
[Get("/pins/{id}")]
|
||||
Task<PlexTokenResponse> GetPinStatus(
|
||||
int id,
|
||||
[Query]
|
||||
string code,
|
||||
[Query] [AliasAs("X-Plex-Client-Identifier")]
|
||||
string clientIdentifier);
|
||||
|
||||
[Get("/user")]
|
||||
Task<PlexUserResponse> GetUser(
|
||||
[Query] [AliasAs("X-Plex-Product")]
|
||||
string product,
|
||||
[Query] [AliasAs("X-Plex-Client-Identifier")]
|
||||
string clientIdentifier,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
[Get("/resources")]
|
||||
Task<List<PlexResource>> GetResources(
|
||||
[Header("X-Plex-Client-Identifier")]
|
||||
string clientIdentifier,
|
||||
[Header("X-Plex-Token")]
|
||||
string token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexLibraryResponse
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Type { get; set; }
|
||||
public int Hidden { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexMediaContainerResponse<T>
|
||||
{
|
||||
public PlexMediaContainerContent<T> MediaContainer { get; set; }
|
||||
}
|
||||
|
||||
public class PlexMediaContainerContent<T>
|
||||
{
|
||||
public List<T> Directory { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexPinResponse
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexResource
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string ProductVersion { get; set; }
|
||||
public string ClientIdentifier { get; set; }
|
||||
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
public bool Owned { get; set; }
|
||||
public string Provides { get; set; }
|
||||
public List<PlexResourceConnection> Connections { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexResourceConnection
|
||||
{
|
||||
public string Protocol { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string Port { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string Local { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexTokenResponse
|
||||
{
|
||||
public string AuthToken { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexUserResponse
|
||||
{
|
||||
public string Email { get; set; }
|
||||
public string AuthToken { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
using Newtonsoft.Json;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
public class PlexSecretStore : IPlexSecretStore
|
||||
{
|
||||
public Task<string> GetClientIdentifier() =>
|
||||
ReadSecrets().Bind(
|
||||
plexSecrets => Optional(plexSecrets.ClientIdentifier).Match(
|
||||
Task.FromResult,
|
||||
async () =>
|
||||
{
|
||||
string identifier = GenerateClientIdentifier();
|
||||
plexSecrets.ClientIdentifier = identifier;
|
||||
await SaveSecrets(plexSecrets);
|
||||
return identifier;
|
||||
}));
|
||||
|
||||
public Task<List<PlexUserAuthToken>> GetUserAuthTokens() =>
|
||||
ReadSecrets().Map(
|
||||
s => Optional(s.UserAuthTokens).Match(
|
||||
tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(),
|
||||
() => new List<PlexUserAuthToken>()));
|
||||
|
||||
public Task<List<PlexServerAuthToken>> GetServerAuthTokens() =>
|
||||
ReadSecrets().Map(
|
||||
s => Optional(s.ServerAuthTokens).Match(
|
||||
tokens => tokens.Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value)).ToList(),
|
||||
() => new List<PlexServerAuthToken>()));
|
||||
|
||||
public Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier) =>
|
||||
ReadSecrets().Map(
|
||||
s => Optional(s.ServerAuthTokens.SingleOrDefault(kvp => kvp.Key == clientIdentifier))
|
||||
.Map(kvp => new PlexServerAuthToken(kvp.Key, kvp.Value)));
|
||||
|
||||
public Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken) =>
|
||||
ReadSecrets().Bind(
|
||||
secrets =>
|
||||
{
|
||||
secrets.UserAuthTokens ??= new Dictionary<string, string>();
|
||||
secrets.UserAuthTokens[userAuthToken.Email] = userAuthToken.AuthToken;
|
||||
return SaveSecrets(secrets);
|
||||
});
|
||||
|
||||
public Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken) =>
|
||||
ReadSecrets().Bind(
|
||||
secrets =>
|
||||
{
|
||||
secrets.ServerAuthTokens ??= new Dictionary<string, string>();
|
||||
secrets.ServerAuthTokens[serverAuthToken.ClientIdentifier] = serverAuthToken.AuthToken;
|
||||
return SaveSecrets(secrets);
|
||||
});
|
||||
|
||||
private static Task<PlexSecrets> ReadSecrets() =>
|
||||
File.ReadAllTextAsync(FileSystemLayout.PlexSecretsPath)
|
||||
.Map(JsonConvert.DeserializeObject<PlexSecrets>)
|
||||
.Map(s => Optional(s).IfNone(new PlexSecrets()));
|
||||
|
||||
private static Task<Unit> SaveSecrets(PlexSecrets plexSecrets) =>
|
||||
Some(JsonConvert.SerializeObject(plexSecrets)).Match(
|
||||
s => File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, s).ToUnit(),
|
||||
Task.FromResult(Unit.Default));
|
||||
|
||||
private static string GenerateClientIdentifier() =>
|
||||
Convert.ToBase64String(Guid.NewGuid().ToByteArray())
|
||||
.TrimEnd('=')
|
||||
.Replace("/", "_")
|
||||
.Replace("+", "-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
public class PlexSecrets
|
||||
{
|
||||
public string ClientIdentifier { get; set; }
|
||||
public Dictionary<string, string> UserAuthTokens { get; set; }
|
||||
public Dictionary<string, string> ServerAuthTokens { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using LanguageExt;
|
||||
using Refit;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
public class PlexServerApiClient : IPlexServerApiClient
|
||||
{
|
||||
public async Task<Either<BaseError, List<PlexMediaSourceLibrary>>> GetLibraries(
|
||||
PlexMediaSourceConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
|
||||
List<PlexLibraryResponse> directory =
|
||||
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
|
||||
return directory
|
||||
.Filter(l => l.Hidden == 0)
|
||||
.Map(Project)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<PlexMediaSourceLibrary> Project(PlexLibraryResponse response) =>
|
||||
response.Type switch
|
||||
{
|
||||
"show" => new PlexMediaSourceLibrary
|
||||
{
|
||||
Key = response.Key,
|
||||
Name = response.Title,
|
||||
MediaType = MediaType.TvShow
|
||||
},
|
||||
"movie" => new PlexMediaSourceLibrary
|
||||
{
|
||||
Key = response.Key,
|
||||
Name = response.Title,
|
||||
MediaType = MediaType.Movie
|
||||
},
|
||||
// TODO: "artist" for music libraries
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Infrastructure.Plex.Models;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
public class PlexTvApiClient : IPlexTvApiClient
|
||||
{
|
||||
private const string AppName = "ErsatzTV";
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
|
||||
private readonly IPlexTvApi _plexTvApi;
|
||||
|
||||
public PlexTvApiClient(IPlexTvApi plexTvApi, IPlexSecretStore plexSecretStore)
|
||||
{
|
||||
// var client = new HttpClient(new HttpLoggingHandler()) { BaseAddress = new Uri("https://plex.tv/api/v2") };
|
||||
|
||||
_plexTvApi = plexTvApi; // RestService.For<IPlexTvApi>(client);
|
||||
_plexSecretStore = plexSecretStore;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<PlexMediaSource>>> GetServers()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = new List<PlexMediaSource>();
|
||||
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
|
||||
foreach (PlexUserAuthToken token in await _plexSecretStore.GetUserAuthTokens())
|
||||
{
|
||||
List<PlexResource> resources = await _plexTvApi.GetResources(clientIdentifier, token.AuthToken);
|
||||
IEnumerable<PlexMediaSource> sources = resources
|
||||
.Filter(r => r.Provides.Split(",").Any(p => p == "server"))
|
||||
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future
|
||||
.Map(
|
||||
resource =>
|
||||
{
|
||||
var serverAuthToken = new PlexServerAuthToken(
|
||||
resource.ClientIdentifier,
|
||||
resource.AccessToken);
|
||||
|
||||
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
|
||||
|
||||
var source = new PlexMediaSource
|
||||
{
|
||||
Name = resource.Name,
|
||||
ProductVersion = resource.ProductVersion,
|
||||
ClientIdentifier = resource.ClientIdentifier,
|
||||
Connections = resource.Connections
|
||||
.Map(c => new PlexMediaSourceConnection { Uri = c.Uri }).ToList()
|
||||
};
|
||||
|
||||
return source;
|
||||
});
|
||||
result.AddRange(sources);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexAuthPin>> StartPinFlow()
|
||||
{
|
||||
try
|
||||
{
|
||||
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
|
||||
PlexPinResponse pinResponse = await _plexTvApi.StartPinFlow(AppName, clientIdentifier);
|
||||
return new PlexAuthPin(pinResponse.Id, pinResponse.Code, clientIdentifier);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryCompletePinFlow(PlexAuthPin authPin)
|
||||
{
|
||||
try
|
||||
{
|
||||
PlexTokenResponse response = await _plexTvApi.GetPinStatus(
|
||||
authPin.Id,
|
||||
authPin.Code,
|
||||
authPin.ClientIdentifier);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.AuthToken))
|
||||
{
|
||||
PlexUserResponse user = await _plexTvApi.GetUser(
|
||||
AppName,
|
||||
authPin.ClientIdentifier,
|
||||
response.AuthToken);
|
||||
|
||||
var token = new PlexUserAuthToken(user.Email, user.AuthToken);
|
||||
await _plexSecretStore.UpsertUserAuthToken(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user