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