Skip to content

Commit

Permalink
feat(plex): add support for fetching friends' watchlists
Browse files Browse the repository at this point in the history
  • Loading branch information
maxnatamo committed Sep 8, 2024
1 parent cfe5919 commit abe0f87
Show file tree
Hide file tree
Showing 16 changed files with 333 additions and 8 deletions.
24 changes: 22 additions & 2 deletions src/API/src/Services/WatchlistSyncService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Fetcharr.API.Pipeline;
using Fetcharr.Models.Configuration;
using Fetcharr.Provider.Plex;
using Fetcharr.Provider.Plex.Models;

using Microsoft.Extensions.Options;

namespace Fetcharr.API.Services
{
/// <summary>
Expand All @@ -12,16 +15,17 @@ public class WatchlistSyncService(
PlexClient plexClient,
SonarrSeriesQueue sonarrSeriesQueue,
RadarrMovieQueue radarrMovieQueue,
IOptions<FetcharrConfiguration> configuration,
ILogger<WatchlistSyncService> logger)
: BasePeriodicService(TimeSpan.FromSeconds(30), logger)
{
public override async Task InvokeAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Syncing Plex watchlist...");

MediaResponse<WatchlistMetadataItem> items = await plexClient.Watchlist.FetchWatchlistAsync(limit: 5);
IEnumerable<WatchlistMetadataItem> watchlistItems = await this.GetAllWatchlistsAsync();

foreach(WatchlistMetadataItem item in items.MediaContainer.Metadata)
foreach(WatchlistMetadataItem item in watchlistItems)
{
PlexMetadataItem? metadata = await plexClient.Metadata.GetMetadataFromRatingKeyAsync(item.RatingKey);
if(metadata is null)
Expand All @@ -46,5 +50,21 @@ public override async Task InvokeAsync(CancellationToken cancellationToken)
await queue.EnqueueAsync(metadata, cancellationToken);
}
}

private async Task<IEnumerable<WatchlistMetadataItem>> GetAllWatchlistsAsync()
{
List<WatchlistMetadataItem> watchlistItems = [];

// Add own watchlist
watchlistItems.AddRange(await plexClient.Watchlist.FetchWatchlistAsync(limit: 5));

// Add friends' watchlists, if enabled.
if(configuration.Value.Plex.IncludeFriendsWatchlist)
{
watchlistItems.AddRange(await plexClient.FriendsWatchlistClient.FetchAllWatchlistsAsync());
}

return watchlistItems;
}
}
}
4 changes: 3 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
<ItemGroup>
<PackageVersion Include="Flurl.Http" Version="4.0.2" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"/>
<PackageVersion Include="GraphQL.Client" Version="6.1.0" />
<PackageVersion Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.1.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
Expand Down
131 changes: 131 additions & 0 deletions src/Provider.Plex/src/Clients/PlexGraphQLClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using Fetcharr.Cache.Core;
using Fetcharr.Models.Configuration;
using Fetcharr.Provider.Plex.Models;
using Fetcharr.Shared.GraphQL;

using GraphQL;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.SystemTextJson;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Fetcharr.Provider.Plex.Clients
{
/// <summary>
/// Client for interacting with Plex' GraphQL API.
/// </summary>
public class PlexGraphQLClient(
IOptions<FetcharrConfiguration> configuration,
[FromKeyedServices("plex-graphql")] ICachingProvider cachingProvider)
{
/// <summary>
/// Gets the GraphQL endpoint for Plex.
/// </summary>
public const string GraphQLEndpoint = "https://community.plex.tv/api";

private readonly GraphQLHttpClient _client =
new GraphQLHttpClient(PlexGraphQLClient.GraphQLEndpoint, new SystemTextJsonSerializer())
.WithAutomaticPersistedQueries(_ => true)
.WithHeader("X-Plex-Token", configuration.Value.Plex.ApiToken)
.WithHeader("X-Plex-Client-Identifier", "fetcharr");

/// <summary>
/// Gets the watchlist of a Plex account, who's a friend of the current plex account.
/// </summary>
public async Task<IEnumerable<WatchlistMetadataItem>> GetFriendWatchlistAsync(
string userId,
int count = 100,
string? cursor = null)
{
string cacheKey = $"friend-watchlist-{userId}";

CacheValue<IEnumerable<WatchlistMetadataItem>> cachedResponse = await cachingProvider
.GetAsync<IEnumerable<WatchlistMetadataItem>>(cacheKey);

if(cachedResponse.HasValue)
{
return cachedResponse.Value;
}

GraphQLRequest request = new()
{
Query = """
query GetFriendWatchlist($uuid: ID = "", $first: PaginationInt!, $after: String) {
user(id: $uuid) {
watchlist(first: $first, after: $after) {
nodes {
... on MetadataItem {
title
ratingKey: id
year
type
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
""",
OperationName = "GetFriendWatchlist",
Variables = new
{
uuid = userId,
first = count,
after = cursor ?? string.Empty
}
};

GraphQLResponse<PlexUserWatchlistResponseType> response = await this._client
.SendQueryAsync<PlexUserWatchlistResponseType>(request);

response.ThrowIfErrors(message: "Failed to fetch friend's watchlist from Plex");

IEnumerable<WatchlistMetadataItem> watchlistItems = response.Data.User.Watchlist.Nodes;

await cachingProvider.SetAsync(cacheKey, watchlistItems, expiration: TimeSpan.FromHours(4));
return watchlistItems;
}

/// <summary>
/// Gets all the friends of the current Plex account and returns them.
/// </summary>
public async Task<IEnumerable<PlexFriendUser>> GetAllFriendsAsync()
{
CacheValue<IEnumerable<PlexFriendUser>> cachedResponse = await cachingProvider
.GetAsync<IEnumerable<PlexFriendUser>>("friends-list");

if(cachedResponse.HasValue)
{
return cachedResponse.Value;
}

GraphQLRequest request = new()
{
Query = """
query {
allFriendsV2 {
user {
id
username
}
}
}
"""
};

GraphQLResponse<PlexFriendListResponseType> response = await this._client
.SendQueryAsync<PlexFriendListResponseType>(request);

response.ThrowIfErrors(message: "Failed to fetch friends list from Plex");

IEnumerable<PlexFriendUser> friends = response.Data.Friends.Select(v => v.User);

await cachingProvider.SetAsync("friends-list", friends, expiration: TimeSpan.FromHours(4));
return friends;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Fetcharr.Provider.Plex.Clients;

using Microsoft.Extensions.DependencyInjection;

namespace Fetcharr.Provider.Plex.Extensions
Expand All @@ -12,6 +14,9 @@ public static IServiceCollection AddPlexClient(this IServiceCollection services)
services.AddSingleton<PlexClient>();
services.AddSingleton<PlexMetadataClient>();
services.AddSingleton<PlexWatchlistClient>();
services.AddSingleton<PlexFriendsWatchlistClient>();

services.AddSingleton<PlexGraphQLClient>();

return services;
}
Expand Down
5 changes: 5 additions & 0 deletions src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@
<ProjectReference Include="..\..\Models\src\Fetcharr.Models.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="GraphQL.Client" />
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" />
</ItemGroup>

</Project>
10 changes: 10 additions & 0 deletions src/Provider.Plex/src/Models/Friends/PlexFriendListResponseType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Fetcharr.Provider.Plex.Models
{
public class PlexFriendListResponseType
{
[JsonPropertyName("allFriendsV2")]
public List<PlexFriendUserContainer> Friends { get; set; } = [];
}
}
16 changes: 16 additions & 0 deletions src/Provider.Plex/src/Models/Friends/PlexFriendUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;

namespace Fetcharr.Provider.Plex.Models
{
/// <summary>
/// Representation of a friend user account.
/// </summary>
public class PlexFriendUser
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;

[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
}
}
13 changes: 13 additions & 0 deletions src/Provider.Plex/src/Models/Friends/PlexFriendUserContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;

namespace Fetcharr.Provider.Plex.Models
{
/// <summary>
/// Representation of a friend user account container.
/// </summary>
public class PlexFriendUserContainer
{
[JsonPropertyName("user")]
public PlexFriendUser User { get; set; } = new();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;

namespace Fetcharr.Provider.Plex.Models
{
public class PlexUserWatchlistResponseType
{
[JsonPropertyName("user")]
public PlexWatchlistResponseType User { get; set; } = new();
}

public class PlexWatchlistResponseType
{
[JsonPropertyName("watchlist")]
public PaginatedResult<WatchlistMetadataItem> Watchlist { get; set; } = new();
}
}
10 changes: 10 additions & 0 deletions src/Provider.Plex/src/Models/GraphQL/PaginatedResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace Fetcharr.Provider.Plex.Models
{
public class PaginatedResult<T>
{
[JsonPropertyName("nodes")]
public List<T> Nodes { get; set; } = [];
}
}
8 changes: 7 additions & 1 deletion src/Provider.Plex/src/PlexClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ namespace Fetcharr.Provider.Plex
public class PlexClient(
IOptions<FetcharrConfiguration> configuration,
PlexMetadataClient metadataClient,
PlexWatchlistClient watchlistClient)
PlexWatchlistClient watchlistClient,
PlexFriendsWatchlistClient plexFriendsWatchlistClient)
: ExternalProvider
{
private readonly FlurlClient _client =
Expand All @@ -35,6 +36,11 @@ public class PlexClient(
/// </summary>
public readonly PlexWatchlistClient Watchlist = watchlistClient;

/// <summary>
/// Gets the underlying client for interacting with Plex watchlists for friends.
/// </summary>
public readonly PlexFriendsWatchlistClient FriendsWatchlistClient = plexFriendsWatchlistClient;

/// <inheritdoc />
public override async Task<bool> PingAsync(CancellationToken cancellationToken)
{
Expand Down
32 changes: 32 additions & 0 deletions src/Provider.Plex/src/PlexFriendsWatchlistClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Fetcharr.Provider.Plex.Clients;
using Fetcharr.Provider.Plex.Models;

namespace Fetcharr.Provider.Plex
{
/// <summary>
/// Client for fetching friends' watchlists from Plex.
/// </summary>
public class PlexFriendsWatchlistClient(
PlexGraphQLClient plexGraphQLClient)
{
/// <summary>
/// Fetch the watchlists for all the friends the current Plex account and return them.
/// </summary>
/// <param name="count">Maximum amount of items to fetch per watchlist.</param>
public async Task<IEnumerable<WatchlistMetadataItem>> FetchAllWatchlistsAsync(int count = 10)
{
List<WatchlistMetadataItem> joinedWatchlist = [];
IEnumerable<PlexFriendUser> friends = await plexGraphQLClient.GetAllFriendsAsync();

foreach(PlexFriendUser friend in friends)
{
IEnumerable<WatchlistMetadataItem> friendWatchlist = await plexGraphQLClient
.GetFriendWatchlistAsync(friend.Id, count);

joinedWatchlist.AddRange(friendWatchlist);
}

return joinedWatchlist;
}
}
}
11 changes: 7 additions & 4 deletions src/Provider.Plex/src/PlexWatchlistClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class PlexWatchlistClient(
/// </summary>
/// <param name="offset">Offset into the watchlist to fetch from.</param>
/// <param name="limit">Maximum amount of items to retrieve from the watchlist.</param>
public async Task<MediaResponse<WatchlistMetadataItem>> FetchWatchlistAsync(int offset = 0, int limit = 20)
public async Task<IEnumerable<WatchlistMetadataItem>> FetchWatchlistAsync(int offset = 0, int limit = 20)
{
IFlurlResponse response = await this._client
.Request("all")
Expand All @@ -45,18 +45,21 @@ public async Task<MediaResponse<WatchlistMetadataItem>> FetchWatchlistAsync(int

if(response.StatusCode == (int) HttpStatusCode.NotModified)
{
CacheValue<MediaResponse<WatchlistMetadataItem>> cacheValue =
await cachingProvider.GetAsync<MediaResponse<WatchlistMetadataItem>>("watchlist");
CacheValue<IEnumerable<WatchlistMetadataItem>> cacheValue =
await cachingProvider.GetAsync<IEnumerable<WatchlistMetadataItem>>("watchlist");

return cacheValue.Value;
}

MediaResponse<WatchlistMetadataItem> watchlist = await response.GetJsonAsync<MediaResponse<WatchlistMetadataItem>>();
MediaResponse<WatchlistMetadataItem> watchlistContainer = await response
.GetJsonAsync<MediaResponse<WatchlistMetadataItem>>();

if(response.Headers.TryGetFirst("etag", out string? etag))
{
this.lastEtag = etag;
}

IEnumerable<WatchlistMetadataItem> watchlist = watchlistContainer.MediaContainer.Metadata;
await cachingProvider.SetAsync("watchlist", watchlist, expiration: TimeSpan.FromHours(1));

return watchlist;
Expand Down
1 change: 1 addition & 0 deletions src/Shared/src/Fetcharr.Shared.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="GraphQL.Client" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>

Expand Down
Loading

0 comments on commit abe0f87

Please sign in to comment.