Browse Source

fix: plex sign in (#2990)

pull/2991/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
a593738637
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 13
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Plex/Commands/DeletePlexMediaSource.cs
  3. 29
      ErsatzTV.Application/Plex/Commands/DeletePlexMediaSourceHandler.cs
  4. 2
      ErsatzTV.Application/Plex/Commands/StartPlexPinFlow.cs
  5. 2
      ErsatzTV.Application/Plex/Commands/StartPlexPinFlowHandler.cs
  6. 75
      ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs
  7. 42
      ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs
  8. 3
      ErsatzTV.Application/Plex/Mapper.cs
  9. 3
      ErsatzTV.Application/Plex/PlexMediaSourceViewModel.cs
  10. 2
      ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs
  11. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs
  12. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs
  13. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  14. 7063
      ErsatzTV.Infrastructure.MySql/Migrations/20260829013602_Add_PlexMediaSource_MissingSince.Designer.cs
  15. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20260829013602_Add_PlexMediaSource_MissingSince.cs
  16. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  17. 6890
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260829013516_Add_PlexMediaSource_MissingSince.Designer.cs
  18. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260829013516_Add_PlexMediaSource_MissingSince.cs
  19. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  20. 8
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  21. 4
      ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs
  22. 9
      ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs
  23. 24
      ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs
  24. 96
      ErsatzTV/Pages/PlexMediaSources.razor
  25. 12
      ErsatzTV/Services/SchedulerService.cs
  26. 37
      ErsatzTV/Shared/RemovePlexMediaSourceDialog.razor

13
CHANGELOG.md

@ -5,8 +5,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,8 +5,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add `Re-authenticate with Plex` button to the Plex media sources page
- Use this to replace the credentials ErsatzTV uses (after a Plex password reset, or after signing out of all Plex devices) without removing media sources or synchronized content
- This registers ErsatzTV with Plex as a new device, so Plex issues a new token; signing in again previously returned the same token
- To revoke the old token, remove the old `ErsatzTV` entry from `Authorized Devices` at plex.tv
### Changed
- Plex servers that are no longer listed at plex.tv are now flagged instead of deleted
- Previously, re-authenticating before re-claiming a server at app.plex.tv would delete that server along with its libraries and all of its media
- Flagged servers are skipped during scans, and are removed only when you choose to remove them
### Fixed
- Fix case where specifically-crafted requests could access management UI over streaming port
- Fix Plex page staying disabled until restart when a sign-in is not completed within two minutes, or when plex.tv cannot be reached
- Fix Plex page showing no indication that ErsatzTV has been signed out of Plex
## [26.8.0] - 2026-08-20
### Added

5
ErsatzTV.Application/Plex/Commands/DeletePlexMediaSource.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record DeletePlexMediaSource(int PlexMediaSourceId) : IRequest<Either<BaseError, Unit>>;

29
ErsatzTV.Application/Plex/Commands/DeletePlexMediaSourceHandler.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
namespace ErsatzTV.Application.Plex;
public class DeletePlexMediaSourceHandler(IMediaSourceRepository mediaSourceRepository, ISearchIndex searchIndex)
: IRequestHandler<DeletePlexMediaSource, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
DeletePlexMediaSource request,
CancellationToken cancellationToken)
{
Option<PlexMediaSource> maybeMediaSource =
await mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
foreach (PlexMediaSource mediaSource in maybeMediaSource)
{
List<int> ids = await mediaSourceRepository.DeletePlex(mediaSource);
await searchIndex.RemoveItems(ids);
searchIndex.Commit();
return Unit.Default;
}
return BaseError.New("Plex media source does not exist.");
}
}

2
ErsatzTV.Application/Plex/Commands/StartPlexPinFlow.cs

@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Plex;
public record StartPlexPinFlow : IRequest<Either<BaseError, string>>;
public record StartPlexPinFlow(bool ForceNewCredentials) : IRequest<Either<BaseError, string>>;

2
ErsatzTV.Application/Plex/Commands/StartPlexPinFlowHandler.cs

@ -20,7 +20,7 @@ public class StartPlexPinFlowHandler : IRequestHandler<StartPlexPinFlow, Either< @@ -20,7 +20,7 @@ public class StartPlexPinFlowHandler : IRequestHandler<StartPlexPinFlow, Either<
public Task<Either<BaseError, string>> Handle(
StartPlexPinFlow request,
CancellationToken cancellationToken) =>
_plexTvApiClient.StartPinFlow().Bind(result => result.Match(
_plexTvApiClient.StartPinFlow(request.ForceNewCredentials).Bind(result => result.Match(
Left: error => Task.FromResult(Left<BaseError, string>(error)),
Right: async pin =>
{

75
ErsatzTV.Application/Plex/Commands/SynchronizePlexMediaSourcesHandler.cs

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
using System.Globalization;
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
@ -43,12 +42,46 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler, @@ -43,12 +42,46 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler,
_logger = logger;
}
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
public async Task<Either<BaseError, List<PlexMediaSource>>> Handle(
SynchronizePlexMediaSources request,
CancellationToken cancellationToken) => _plexTvApiClient.GetServers().BindAsync(SynchronizeAllServers);
CancellationToken cancellationToken)
{
// without credentials plex.tv is never asked, and the empty result would otherwise read as
// "this account has no servers" and flag every media source as missing
List<PlexUserAuthToken> userAuthTokens = await _plexSecretStore.GetUserAuthTokens();
if (userAuthTokens.Count == 0)
{
_entityLocker.UnlockPlex();
return new List<PlexMediaSource>();
}
Either<BaseError, List<PlexMediaSource>> maybeServers = await _plexTvApiClient.GetServers();
foreach (BaseError error in maybeServers.LeftToSeq())
{
// SynchronizeAllServers releases the plex lock, and it does not run for this path
_entityLocker.UnlockPlex();
return error;
}
return await maybeServers.BindAsync(SynchronizeAllServers);
}
private async Task<Either<BaseError, List<PlexMediaSource>>> SynchronizeAllServers(
List<PlexMediaSource> servers)
{
try
{
return await SynchronizeAllServersInner(servers);
}
finally
{
_entityLocker.UnlockPlex();
}
}
private async Task<Either<BaseError, List<PlexMediaSource>>> SynchronizeAllServersInner(
List<PlexMediaSource> servers)
{
List<PlexMediaSource> allExisting = await _mediaSourceRepository.GetAllPlex();
foreach (PlexMediaSource server in servers)
@ -56,23 +89,31 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler, @@ -56,23 +89,31 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler,
await SynchronizeServer(allExisting, server);
}
// delete removed servers
foreach (PlexMediaSource removed in allExisting.Filter(s =>
// a server missing from plex.tv may only be unclaimed (signing out all devices does this),
// and deleting it would take its libraries and all of its media with it; mark it instead and
// let the user remove it explicitly once they know it is really gone
DateTime now = DateTime.UtcNow;
foreach (PlexMediaSource missing in allExisting.Filter(s =>
servers.All(pms => pms.ClientIdentifier != s.ClientIdentifier)))
{
_logger.LogWarning(
"Deleting removed Plex server {ServerName}!",
removed.Id.ToString(CultureInfo.InvariantCulture));
await _mediaSourceRepository.DeletePlex(removed);
if (missing.MissingSince is null)
{
_logger.LogWarning(
"Plex server {ServerName} is no longer listed at plex.tv; it will be skipped until it returns, or until it is removed",
missing.ServerName);
await _mediaSourceRepository.SetPlexMissingSince(missing.Id, now);
}
}
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
{
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
if (mediaSource.MissingSince is null)
{
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
}
}
_entityLocker.UnlockPlex();
return allExisting;
}
@ -104,6 +145,14 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler, @@ -104,6 +145,14 @@ public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler,
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
await _mediaSourceRepository.Update(existing, toAdd, toRemove);
// Update can fail silently, so clear this with its own write rather than relying on it
if (existing.MissingSince is not null)
{
_logger.LogInformation("Plex server {ServerName} is listed at plex.tv again", server.ServerName);
await _mediaSourceRepository.SetPlexMissingSince(existing.Id, null);
}
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
if (maybeToken.IsNone)
{

42
ErsatzTV.Application/Plex/Commands/TryCompletePlexPinFlowHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
namespace ErsatzTV.Application.Plex;
@ -7,14 +8,17 @@ namespace ErsatzTV.Application.Plex; @@ -7,14 +8,17 @@ namespace ErsatzTV.Application.Plex;
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IPlexTvApiClient _plexTvApiClient;
public TryCompletePlexPinFlowHandler(
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel)
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
{
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, bool>>
@ -23,16 +27,36 @@ public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinF @@ -23,16 +27,36 @@ public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinF
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
CancellationToken token = linkedTokenSource.Token;
while (!token.IsCancellationRequested)
// the sign-in flow takes the plex lock in the UI; only a completed sign-in reaches
// SynchronizePlexMediaSources, which is what releases it, so every other exit unlocks here
var authenticated = false;
try
{
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
if (result)
while (!token.IsCancellationRequested)
{
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
return true;
}
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
if (result)
{
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
authenticated = true;
return true;
}
await Task.Delay(TimeSpan.FromSeconds(1), token);
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// the two minute window ended without a sign-in
}
finally
{
if (!authenticated)
{
_entityLocker.UnlockPlex();
}
}
return false;

3
ErsatzTV.Application/Plex/Mapper.cs

@ -8,7 +8,8 @@ internal static class Mapper @@ -8,7 +8,8 @@ internal static class Mapper
new(
plexMediaSource.Id,
plexMediaSource.ServerName,
Optional(plexMediaSource.Connections.SingleOrDefault(c => c.IsActive)).Match(c => c.Uri, string.Empty));
Optional(plexMediaSource.Connections.SingleOrDefault(c => c.IsActive)).Match(c => c.Uri, string.Empty),
plexMediaSource.MissingSince);
internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);

3
ErsatzTV.Application/Plex/PlexMediaSourceViewModel.cs

@ -2,4 +2,5 @@ @@ -2,4 +2,5 @@
namespace ErsatzTV.Application.Plex;
public record PlexMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
public record PlexMediaSourceViewModel(int Id, string Name, string Address, DateTime? MissingSince)
: MediaSourceViewModel(Id, Name);

2
ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs

@ -12,4 +12,6 @@ public class PlexMediaSource : MediaSource @@ -12,4 +12,6 @@ public class PlexMediaSource : MediaSource
public List<PlexConnection> Connections { get; set; }
public List<PlexPathReplacement> PathReplacements { get; set; }
public DateTime? LastCollectionsScan { get; set; }
public DateTime? MissingSince { get; set; }
}

2
ErsatzTV.Core/Interfaces/Plex/IPlexSecretStore.cs

@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Interfaces.Plex; @@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexSecretStore
{
Task<string> GetClientIdentifier();
string GenerateClientIdentifier();
Task<Unit> UpsertClientIdentifier(string clientIdentifier);
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);

2
ErsatzTV.Core/Interfaces/Plex/IPlexTvApiClient.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Plex; @@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexTvApiClient
{
Task<Either<BaseError, PlexAuthPin>> StartPinFlow();
Task<Either<BaseError, PlexAuthPin>> StartPinFlow(bool forceNewCredentials);
Task<bool> TryCompletePinFlow(PlexAuthPin authPin);
Task<Either<BaseError, List<PlexMediaSource>>> GetServers();
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs

@ -98,4 +98,6 @@ public interface IMediaSourceRepository @@ -98,4 +98,6 @@ public interface IMediaSourceRepository
Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource);
Task<Unit> UpdateLastCollectionScan(PlexMediaSource plexMediaSource);
Task<Unit> SetPlexMissingSince(int plexMediaSourceId, DateTime? missingSince);
}

7063
ErsatzTV.Infrastructure.MySql/Migrations/20260829013602_Add_PlexMediaSource_MissingSince.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20260829013602_Add_PlexMediaSource_MissingSince.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlexMediaSource_MissingSince : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "MissingSince",
table: "PlexMediaSource",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MissingSince",
table: "PlexMediaSource");
}
}
}

3
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -4115,6 +4115,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4115,6 +4115,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("datetime(6)");
b.Property<DateTime?>("MissingSince")
.HasColumnType("datetime(6)");
b.Property<string>("Platform")
.HasColumnType("longtext");

6890
ErsatzTV.Infrastructure.Sqlite/Migrations/20260829013516_Add_PlexMediaSource_MissingSince.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20260829013516_Add_PlexMediaSource_MissingSince.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlexMediaSource_MissingSince : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "MissingSince",
table: "PlexMediaSource",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MissingSince",
table: "PlexMediaSource");
}
}
}

3
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -3942,6 +3942,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3942,6 +3942,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("TEXT");
b.Property<DateTime?>("MissingSince")
.HasColumnType("TEXT");
b.Property<string>("Platform")
.HasColumnType("TEXT");

8
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -990,4 +990,12 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory @@ -990,4 +990,12 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory
"UPDATE PlexMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { plexMediaSource.LastCollectionsScan, plexMediaSource.Id }).ToUnit();
}
public async Task<Unit> SetPlexMissingSince(int plexMediaSourceId, DateTime? missingSince)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMediaSource SET MissingSince = @MissingSince WHERE Id = @Id",
new { MissingSince = missingSince, Id = plexMediaSourceId }).ToUnit();
}
}

4
ErsatzTV.Infrastructure/Plex/IPlexTvApi.cs

@ -10,6 +10,8 @@ public interface IPlexTvApi @@ -10,6 +10,8 @@ public interface IPlexTvApi
Task<PlexPinResponse> StartPinFlow(
[Query] [AliasAs("X-Plex-Product")]
string product,
[Query] [AliasAs("X-Plex-Version")]
string version,
[Query] [AliasAs("X-Plex-Client-Identifier")]
string clientIdentifier,
[Query]
@ -27,6 +29,8 @@ public interface IPlexTvApi @@ -27,6 +29,8 @@ public interface IPlexTvApi
Task<PlexUserResponse> GetUser(
[Query] [AliasAs("X-Plex-Product")]
string product,
[Query] [AliasAs("X-Plex-Version")]
string version,
[Query] [AliasAs("X-Plex-Client-Identifier")]
string clientIdentifier,
[Query] [AliasAs("X-Plex-Token")]

9
ErsatzTV.Infrastructure/Plex/PlexSecretStore.cs

@ -18,6 +18,13 @@ public class PlexSecretStore : IPlexSecretStore @@ -18,6 +18,13 @@ public class PlexSecretStore : IPlexSecretStore
return identifier;
}));
public Task<Unit> UpsertClientIdentifier(string clientIdentifier) =>
ReadSecrets().Bind(secrets =>
{
secrets.ClientIdentifier = clientIdentifier;
return SaveSecrets(secrets);
});
public Task<List<PlexUserAuthToken>> GetUserAuthTokens() =>
ReadSecrets().Map(s => Optional(s.UserAuthTokens).Match(
tokens => tokens.Map(kvp => new PlexUserAuthToken(kvp.Key, kvp.Value)).ToList(),
@ -62,7 +69,7 @@ public class PlexSecretStore : IPlexSecretStore @@ -62,7 +69,7 @@ public class PlexSecretStore : IPlexSecretStore
s => File.WriteAllTextAsync(FileSystemLayout.PlexSecretsPath, s).ToUnit(),
Task.FromResult(Unit.Default));
private static string GenerateClientIdentifier() =>
public string GenerateClientIdentifier() =>
Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.TrimEnd('=')
.Replace("/", "_")

24
ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
using ErsatzTV.Core;
using System.Net;
using System.Reflection;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
@ -10,6 +12,8 @@ namespace ErsatzTV.Infrastructure.Plex; @@ -10,6 +12,8 @@ namespace ErsatzTV.Infrastructure.Plex;
public class PlexTvApiClient : IPlexTvApiClient
{
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private const string AppName = "ErsatzTV";
private readonly ILogger<PlexTvApiClient> _logger;
private readonly IPlexSecretStore _plexSecretStore;
@ -83,7 +87,7 @@ public class PlexTvApiClient : IPlexTvApiClient @@ -83,7 +87,7 @@ public class PlexTvApiClient : IPlexTvApiClient
}
catch (ApiException apiException)
{
if (apiException.ReasonPhrase == "Unauthorized")
if (apiException.StatusCode == HttpStatusCode.Unauthorized)
{
await _plexSecretStore.DeleteAll();
}
@ -97,12 +101,19 @@ public class PlexTvApiClient : IPlexTvApiClient @@ -97,12 +101,19 @@ public class PlexTvApiClient : IPlexTvApiClient
}
}
public async Task<Either<BaseError, PlexAuthPin>> StartPinFlow()
public async Task<Either<BaseError, PlexAuthPin>> StartPinFlow(bool forceNewCredentials)
{
try
{
string clientIdentifier = await _plexSecretStore.GetClientIdentifier();
PlexPinResponse pinResponse = await _plexTvApi.StartPinFlow(AppName, clientIdentifier);
// plex issues auth tokens per device, so signing in again with the stored client
// identifier returns the token this device already has; a new identifier is what
// makes plex mint a new one. It is not saved until the sign-in completes, so an
// abandoned sign-in leaves the existing credentials alone.
string clientIdentifier = forceNewCredentials
? _plexSecretStore.GenerateClientIdentifier()
: await _plexSecretStore.GetClientIdentifier();
PlexPinResponse pinResponse = await _plexTvApi.StartPinFlow(AppName, InfoVersion, clientIdentifier);
return new PlexAuthPin(pinResponse.Id, pinResponse.Code, clientIdentifier);
}
catch (Exception ex)
@ -125,9 +136,12 @@ public class PlexTvApiClient : IPlexTvApiClient @@ -125,9 +136,12 @@ public class PlexTvApiClient : IPlexTvApiClient
{
PlexUserResponse user = await _plexTvApi.GetUser(
AppName,
InfoVersion,
authPin.ClientIdentifier,
response.AuthToken);
await _plexSecretStore.UpsertClientIdentifier(authPin.ClientIdentifier);
var token = new PlexUserAuthToken(user.Email, user.AuthToken);
await _plexSecretStore.UpsertUserAuthToken(token);

96
ErsatzTV/Pages/PlexMediaSources.razor

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
@page "/media/sources/plex"
@page "/media/sources/plex"
@using ErsatzTV.Application.Plex
@using ErsatzTV.Core.Interfaces.Plex
@implements IDisposable
@ -22,39 +22,54 @@ @@ -22,39 +22,54 @@
Class="ml-8">
Sign out of plex
</MudButton>
<MudTooltip Text="Sign in to Plex again to get a new token for ErsatzTV; media sources and synchronized content are kept. Remove the old ErsatzTV entry from Authorized Devices at plex.tv to revoke the old token.">
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
OnClick="@(_ => AddPlexMediaSource(forceNewCredentials: true))"
Disabled="@Locker.IsPlexLocked()"
Class="ml-4">
Re-authenticate with Plex
</MudButton>
</MudTooltip>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="@(_ => AddPlexMediaSource())"
OnClick="@(_ => AddPlexMediaSource(forceNewCredentials: false))"
Disabled="@Locker.IsPlexLocked()"
Class="ml-8">
Sign in to plex
</MudButton>
}
@if (_mediaSources.Any() && !_isAuthorized)
{
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
OnClick="@(_ => AddPlexMediaSource())"
Disabled="@Locker.IsPlexLocked()"
Class="ml-4">
Fix Plex Credentials
</MudButton>
}
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Plex Media Sources</MudText>
<MudDivider Class="mb-6"/>
@if (_mediaSources.Any() && !_isSignedIn)
{
<MudAlert Severity="Severity.Error" Class="mb-6">
ErsatzTV is not signed in to Plex, so Plex libraries cannot be scanned and Plex content cannot
be played. Use <b>Re-authenticate with Plex</b> to sign in again; media sources and
synchronized content are kept.
</MudAlert>
}
else if (_mediaSources.Any(ms => ms.MissingSince is not null))
{
<MudAlert Severity="Severity.Warning" Class="mb-6">
One or more servers are no longer listed at plex.tv. This is expected after signing out of all
Plex devices; re-claim the server at app.plex.tv and it will be picked up automatically.
Synchronized content is kept until you remove the server.
</MudAlert>
}
<MudTable T="PlexMediaSourceViewModel" Hover="true" Dense="true" Items="_mediaSources">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col style="width: 120px;"/>
<col style="width: 160px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
@ -63,13 +78,23 @@ @@ -63,13 +78,23 @@
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
@if (context.MissingSince is not null)
{
<MudTooltip Text="Not listed at plex.tv; re-claim this server at app.plex.tv, or remove it">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
</MudTooltip>
}
@context.Name
</div>
</MudTd>
<MudTd Style="overflow-wrap: anywhere;">@context.Address</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Refresh Libraries">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@(Locker.IsPlexLocked())"
Disabled="@(Locker.IsPlexLocked() || context.MissingSince is not null)"
OnClick="@(_ => RefreshLibraries(context.Id))">
</MudIconButton>
</MudTooltip>
@ -83,6 +108,15 @@ @@ -83,6 +108,15 @@
Href="@($"media/sources/plex/{context.Id}/paths")">
</MudIconButton>
</MudTooltip>
@if (context.MissingSince is not null)
{
<MudTooltip Text="Remove Server">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@Locker.IsPlexLocked()"
OnClick="@(_ => RemoveMediaSource(context))">
</MudIconButton>
</MudTooltip>
}
</div>
</MudTd>
</RowTemplate>
@ -94,7 +128,7 @@ @@ -94,7 +128,7 @@
@code {
private List<PlexMediaSourceViewModel> _mediaSources = new();
private bool _isAuthorized;
private bool _isSignedIn;
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
@ -103,7 +137,7 @@ @@ -103,7 +137,7 @@
private async Task LoadMediaSources()
{
_isAuthorized = await PlexSecretStore.GetUserAuthTokens().Map(list => Optional(list).Flatten().Any());
_isSignedIn = await PlexSecretStore.GetUserAuthTokens().Map(list => Optional(list).Flatten().Any());
_mediaSources = await Mediator.Send(new GetAllPlexMediaSources());
}
@ -122,11 +156,33 @@ @@ -122,11 +156,33 @@
}
}
private async Task AddPlexMediaSource()
private async Task RemoveMediaSource(PlexMediaSourceViewModel mediaSource)
{
var parameters = new DialogParameters { { "Name", mediaSource.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
IDialogReference dialog = await Dialog.ShowAsync<RemovePlexMediaSourceDialog>(
"Remove Plex Server",
parameters,
options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false })
{
Either<BaseError, Unit> deleteResult = await Mediator.Send(new DeletePlexMediaSource(mediaSource.Id));
foreach (BaseError error in deleteResult.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error removing plex media source: {Error}", error.Value);
}
await LoadMediaSources();
}
}
private async Task AddPlexMediaSource(bool forceNewCredentials)
{
if (Locker.LockPlex())
{
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow());
Either<BaseError, string> maybeUrl = await Mediator.Send(new StartPlexPinFlow(forceNewCredentials));
await maybeUrl.Match(
async url =>
{

12
ErsatzTV/Services/SchedulerService.cs

@ -224,8 +224,20 @@ public class SchedulerService : BackgroundService @@ -224,8 +224,20 @@ public class SchedulerService : BackgroundService
var mediaSourceIds = new System.Collections.Generic.HashSet<int>();
// servers that plex.tv no longer lists cannot be reached, so don't queue scans for them
List<int> missingMediaSourceIds = await dbContext.PlexMediaSources
.AsNoTracking()
.Filter(s => s.MissingSince != null)
.Map(s => s.Id)
.ToListAsync(cancellationToken);
foreach (PlexLibrary library in dbContext.PlexLibraries.AsNoTracking().Filter(l => l.ShouldSyncItems))
{
if (missingMediaSourceIds.Contains(library.MediaSourceId))
{
continue;
}
mediaSourceIds.Add(library.MediaSourceId);
if (_entityLocker.LockLibrary(library.Id))

37
ErsatzTV/Shared/RemovePlexMediaSourceDialog.razor

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
<div @onkeydown="@OnKeyDown">
<MudDialog>
<DialogContent>
<MudContainer>
<MudHighlighter Class="mud-primary-text"
Style="background-color: transparent; font-weight: bold"
Text="@($"Do you really want to remove {Name}? All synchronized content from this server will be removed.")"/>
</MudContainer>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">Remove</MudButton>
</DialogActions>
</MudDialog>
</div>
@code {
[Parameter]
public string Name { get; set; }
[CascadingParameter]
IMudDialogInstance MudDialog { get; set; }
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")
{
Submit();
}
}
}
Loading…
Cancel
Save