diff --git a/src/API/src/Services/WatchlistSyncService.cs b/src/API/src/Services/WatchlistSyncService.cs
index b242b16..d900689 100644
--- a/src/API/src/Services/WatchlistSyncService.cs
+++ b/src/API/src/Services/WatchlistSyncService.cs
@@ -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
{
///
@@ -12,6 +15,7 @@ public class WatchlistSyncService(
PlexClient plexClient,
SonarrSeriesQueue sonarrSeriesQueue,
RadarrMovieQueue radarrMovieQueue,
+ IOptions configuration,
ILogger logger)
: BasePeriodicService(TimeSpan.FromSeconds(30), logger)
{
@@ -19,9 +23,9 @@ public override async Task InvokeAsync(CancellationToken cancellationToken)
{
logger.LogInformation("Syncing Plex watchlist...");
- MediaResponse items = await plexClient.Watchlist.FetchWatchlistAsync(limit: 5);
+ IEnumerable 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)
@@ -46,5 +50,21 @@ public override async Task InvokeAsync(CancellationToken cancellationToken)
await queue.EnqueueAsync(metadata, cancellationToken);
}
}
+
+ private async Task> GetAllWatchlistsAsync()
+ {
+ List 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;
+ }
}
}
\ No newline at end of file
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 5d69028..150ce45 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -16,7 +16,9 @@
-
+
+
+
diff --git a/src/Provider.Plex/src/Clients/PlexGraphQLClient.cs b/src/Provider.Plex/src/Clients/PlexGraphQLClient.cs
new file mode 100644
index 0000000..de1e440
--- /dev/null
+++ b/src/Provider.Plex/src/Clients/PlexGraphQLClient.cs
@@ -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
+{
+ ///
+ /// Client for interacting with Plex' GraphQL API.
+ ///
+ public class PlexGraphQLClient(
+ IOptions configuration,
+ [FromKeyedServices("plex-graphql")] ICachingProvider cachingProvider)
+ {
+ ///
+ /// Gets the GraphQL endpoint for Plex.
+ ///
+ 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");
+
+ ///
+ /// Gets the watchlist of a Plex account, who's a friend of the current plex account.
+ ///
+ public async Task> GetFriendWatchlistAsync(
+ string userId,
+ int count = 100,
+ string? cursor = null)
+ {
+ string cacheKey = $"friend-watchlist-{userId}";
+
+ CacheValue> cachedResponse = await cachingProvider
+ .GetAsync>(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 response = await this._client
+ .SendQueryAsync(request);
+
+ response.ThrowIfErrors(message: "Failed to fetch friend's watchlist from Plex");
+
+ IEnumerable watchlistItems = response.Data.User.Watchlist.Nodes;
+
+ await cachingProvider.SetAsync(cacheKey, watchlistItems, expiration: TimeSpan.FromHours(4));
+ return watchlistItems;
+ }
+
+ ///
+ /// Gets all the friends of the current Plex account and returns them.
+ ///
+ public async Task> GetAllFriendsAsync()
+ {
+ CacheValue> cachedResponse = await cachingProvider
+ .GetAsync>("friends-list");
+
+ if(cachedResponse.HasValue)
+ {
+ return cachedResponse.Value;
+ }
+
+ GraphQLRequest request = new()
+ {
+ Query = """
+ query {
+ allFriendsV2 {
+ user {
+ id
+ username
+ }
+ }
+ }
+ """
+ };
+
+ GraphQLResponse response = await this._client
+ .SendQueryAsync(request);
+
+ response.ThrowIfErrors(message: "Failed to fetch friends list from Plex");
+
+ IEnumerable friends = response.Data.Friends.Select(v => v.User);
+
+ await cachingProvider.SetAsync("friends-list", friends, expiration: TimeSpan.FromHours(4));
+ return friends;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs b/src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs
index 24d0b2a..55617e8 100644
--- a/src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs
+++ b/src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs
@@ -1,3 +1,5 @@
+using Fetcharr.Provider.Plex.Clients;
+
using Microsoft.Extensions.DependencyInjection;
namespace Fetcharr.Provider.Plex.Extensions
@@ -12,6 +14,9 @@ public static IServiceCollection AddPlexClient(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
return services;
}
diff --git a/src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj b/src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj
index f7b880c..e500b2c 100644
--- a/src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj
+++ b/src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj
@@ -14,4 +14,9 @@
+
+
+
+
+
diff --git a/src/Provider.Plex/src/Models/Friends/PlexFriendListResponseType.cs b/src/Provider.Plex/src/Models/Friends/PlexFriendListResponseType.cs
new file mode 100644
index 0000000..f7c1f3d
--- /dev/null
+++ b/src/Provider.Plex/src/Models/Friends/PlexFriendListResponseType.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+
+namespace Fetcharr.Provider.Plex.Models
+{
+ public class PlexFriendListResponseType
+ {
+ [JsonPropertyName("allFriendsV2")]
+ public List Friends { get; set; } = [];
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/Models/Friends/PlexFriendUser.cs b/src/Provider.Plex/src/Models/Friends/PlexFriendUser.cs
new file mode 100644
index 0000000..cae18b3
--- /dev/null
+++ b/src/Provider.Plex/src/Models/Friends/PlexFriendUser.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+
+namespace Fetcharr.Provider.Plex.Models
+{
+ ///
+ /// Representation of a friend user account.
+ ///
+ public class PlexFriendUser
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/Models/Friends/PlexFriendUserContainer.cs b/src/Provider.Plex/src/Models/Friends/PlexFriendUserContainer.cs
new file mode 100644
index 0000000..bc067b7
--- /dev/null
+++ b/src/Provider.Plex/src/Models/Friends/PlexFriendUserContainer.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+
+namespace Fetcharr.Provider.Plex.Models
+{
+ ///
+ /// Representation of a friend user account container.
+ ///
+ public class PlexFriendUserContainer
+ {
+ [JsonPropertyName("user")]
+ public PlexFriendUser User { get; set; } = new();
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/Models/Friends/PlexUserWatchlistResponseType.cs b/src/Provider.Plex/src/Models/Friends/PlexUserWatchlistResponseType.cs
new file mode 100644
index 0000000..292275a
--- /dev/null
+++ b/src/Provider.Plex/src/Models/Friends/PlexUserWatchlistResponseType.cs
@@ -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 Watchlist { get; set; } = new();
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/Models/GraphQL/PaginatedResult.cs b/src/Provider.Plex/src/Models/GraphQL/PaginatedResult.cs
new file mode 100644
index 0000000..d7393eb
--- /dev/null
+++ b/src/Provider.Plex/src/Models/GraphQL/PaginatedResult.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+
+namespace Fetcharr.Provider.Plex.Models
+{
+ public class PaginatedResult
+ {
+ [JsonPropertyName("nodes")]
+ public List Nodes { get; set; } = [];
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/PlexClient.cs b/src/Provider.Plex/src/PlexClient.cs
index 07fac0f..58c5706 100644
--- a/src/Provider.Plex/src/PlexClient.cs
+++ b/src/Provider.Plex/src/PlexClient.cs
@@ -14,7 +14,8 @@ namespace Fetcharr.Provider.Plex
public class PlexClient(
IOptions configuration,
PlexMetadataClient metadataClient,
- PlexWatchlistClient watchlistClient)
+ PlexWatchlistClient watchlistClient,
+ PlexFriendsWatchlistClient plexFriendsWatchlistClient)
: ExternalProvider
{
private readonly FlurlClient _client =
@@ -35,6 +36,11 @@ public class PlexClient(
///
public readonly PlexWatchlistClient Watchlist = watchlistClient;
+ ///
+ /// Gets the underlying client for interacting with Plex watchlists for friends.
+ ///
+ public readonly PlexFriendsWatchlistClient FriendsWatchlistClient = plexFriendsWatchlistClient;
+
///
public override async Task PingAsync(CancellationToken cancellationToken)
{
diff --git a/src/Provider.Plex/src/PlexFriendsWatchlistClient.cs b/src/Provider.Plex/src/PlexFriendsWatchlistClient.cs
new file mode 100644
index 0000000..99d2687
--- /dev/null
+++ b/src/Provider.Plex/src/PlexFriendsWatchlistClient.cs
@@ -0,0 +1,32 @@
+using Fetcharr.Provider.Plex.Clients;
+using Fetcharr.Provider.Plex.Models;
+
+namespace Fetcharr.Provider.Plex
+{
+ ///
+ /// Client for fetching friends' watchlists from Plex.
+ ///
+ public class PlexFriendsWatchlistClient(
+ PlexGraphQLClient plexGraphQLClient)
+ {
+ ///
+ /// Fetch the watchlists for all the friends the current Plex account and return them.
+ ///
+ /// Maximum amount of items to fetch per watchlist.
+ public async Task> FetchAllWatchlistsAsync(int count = 10)
+ {
+ List joinedWatchlist = [];
+ IEnumerable friends = await plexGraphQLClient.GetAllFriendsAsync();
+
+ foreach(PlexFriendUser friend in friends)
+ {
+ IEnumerable friendWatchlist = await plexGraphQLClient
+ .GetFriendWatchlistAsync(friend.Id, count);
+
+ joinedWatchlist.AddRange(friendWatchlist);
+ }
+
+ return joinedWatchlist;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Provider.Plex/src/PlexWatchlistClient.cs b/src/Provider.Plex/src/PlexWatchlistClient.cs
index 15bdd13..6258c44 100644
--- a/src/Provider.Plex/src/PlexWatchlistClient.cs
+++ b/src/Provider.Plex/src/PlexWatchlistClient.cs
@@ -33,7 +33,7 @@ public class PlexWatchlistClient(
///
/// Offset into the watchlist to fetch from.
/// Maximum amount of items to retrieve from the watchlist.
- public async Task> FetchWatchlistAsync(int offset = 0, int limit = 20)
+ public async Task> FetchWatchlistAsync(int offset = 0, int limit = 20)
{
IFlurlResponse response = await this._client
.Request("all")
@@ -45,18 +45,21 @@ public async Task> FetchWatchlistAsync(int
if(response.StatusCode == (int) HttpStatusCode.NotModified)
{
- CacheValue> cacheValue =
- await cachingProvider.GetAsync>("watchlist");
+ CacheValue> cacheValue =
+ await cachingProvider.GetAsync>("watchlist");
return cacheValue.Value;
}
- MediaResponse watchlist = await response.GetJsonAsync>();
+ MediaResponse watchlistContainer = await response
+ .GetJsonAsync>();
+
if(response.Headers.TryGetFirst("etag", out string? etag))
{
this.lastEtag = etag;
}
+ IEnumerable watchlist = watchlistContainer.MediaContainer.Metadata;
await cachingProvider.SetAsync("watchlist", watchlist, expiration: TimeSpan.FromHours(1));
return watchlist;
diff --git a/src/Shared/src/Fetcharr.Shared.csproj b/src/Shared/src/Fetcharr.Shared.csproj
index df6852c..eacf1fb 100644
--- a/src/Shared/src/Fetcharr.Shared.csproj
+++ b/src/Shared/src/Fetcharr.Shared.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/Shared/src/GraphQL/GraphQLHttpClientExtensions.cs b/src/Shared/src/GraphQL/GraphQLHttpClientExtensions.cs
new file mode 100644
index 0000000..63c88d7
--- /dev/null
+++ b/src/Shared/src/GraphQL/GraphQLHttpClientExtensions.cs
@@ -0,0 +1,34 @@
+using GraphQL;
+using GraphQL.Client.Http;
+
+namespace Fetcharr.Shared.GraphQL
+{
+ public static class GraphQLHttpClientExtensions
+ {
+ ///
+ /// Appends a default HTTP header to send along with all GraphQL operations.
+ ///
+ /// -instance to add the header onto.
+ /// to allow for chaining calls.
+ public static GraphQLHttpClient WithHeader(this GraphQLHttpClient client, string name, string value)
+ {
+ client.HttpClient.DefaultRequestHeaders.Add(name, value);
+ return client;
+ }
+
+ ///
+ /// Enables Automatic Persisted Queries, when resolve to .
+ ///
+ /// -instance to enable APQ for.
+ /// to allow for chaining calls.
+ public static GraphQLHttpClient WithAutomaticPersistedQueries(
+ this GraphQLHttpClient client,
+ Func? when = null)
+ {
+ when ??= _ => true;
+
+ client.Options.EnableAutomaticPersistedQueries = when;
+ return client;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Shared/src/GraphQL/GraphQLResponseExtensions.cs b/src/Shared/src/GraphQL/GraphQLResponseExtensions.cs
new file mode 100644
index 0000000..c37905a
--- /dev/null
+++ b/src/Shared/src/GraphQL/GraphQLResponseExtensions.cs
@@ -0,0 +1,21 @@
+using GraphQL;
+
+namespace Fetcharr.Shared.GraphQL
+{
+ public static class GraphQLResponseExtensions
+ {
+ ///
+ /// If any errors are present on the response, throw an with all errors.
+ ///
+ /// -instance to check for errors on.
+ public static void ThrowIfErrors(this GraphQLResponse response, string? message = null)
+ {
+ if(response.Errors is { Length: > 0 })
+ {
+ throw new AggregateException(
+ message ?? "Error(s) received from GraphQL endpoint.",
+ response.Errors.Select(error => new Exception(error.Message)));
+ }
+ }
+ }
+}
\ No newline at end of file