Skip to content

Commit d41230f

Browse files
committed
feat(plex): add support for fetching friends' watchlists
1 parent 08e7d86 commit d41230f

16 files changed

Lines changed: 333 additions & 8 deletions

src/API/src/Services/WatchlistSyncService.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
using Fetcharr.API.Pipeline;
2+
using Fetcharr.Models.Configuration;
23
using Fetcharr.Provider.Plex;
34
using Fetcharr.Provider.Plex.Models;
45

6+
using Microsoft.Extensions.Options;
7+
58
namespace Fetcharr.API.Services
69
{
710
/// <summary>
@@ -12,16 +15,17 @@ public class WatchlistSyncService(
1215
PlexClient plexClient,
1316
SonarrSeriesQueue sonarrSeriesQueue,
1417
RadarrMovieQueue radarrMovieQueue,
18+
IOptions<FetcharrConfiguration> configuration,
1519
ILogger<WatchlistSyncService> logger)
1620
: BasePeriodicService(TimeSpan.FromSeconds(30), logger)
1721
{
1822
public override async Task InvokeAsync(CancellationToken cancellationToken)
1923
{
2024
logger.LogInformation("Syncing Plex watchlist...");
2125

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

24-
foreach(WatchlistMetadataItem item in items.MediaContainer.Metadata)
28+
foreach(WatchlistMetadataItem item in watchlistItems)
2529
{
2630
PlexMetadataItem? metadata = await plexClient.Metadata.GetMetadataFromRatingKeyAsync(item.RatingKey);
2731
if(metadata is null)
@@ -46,5 +50,21 @@ public override async Task InvokeAsync(CancellationToken cancellationToken)
4650
await queue.EnqueueAsync(metadata, cancellationToken);
4751
}
4852
}
53+
54+
private async Task<IEnumerable<WatchlistMetadataItem>> GetAllWatchlistsAsync()
55+
{
56+
List<WatchlistMetadataItem> watchlistItems = [];
57+
58+
// Add own watchlist
59+
watchlistItems.AddRange(await plexClient.Watchlist.FetchWatchlistAsync(limit: 5));
60+
61+
// Add friends' watchlists, if enabled.
62+
if(configuration.Value.Plex.IncludeFriendsWatchlist)
63+
{
64+
watchlistItems.AddRange(await plexClient.FriendsWatchlistClient.FetchAllWatchlistsAsync());
65+
}
66+
67+
return watchlistItems;
68+
}
4969
}
5070
}

src/Directory.Packages.props

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
<ItemGroup>
1717
<PackageVersion Include="Flurl.Http" Version="4.0.2" />
1818
<PackageVersion Include="GitVersion.MsBuild" Version="6.0.0" />
19-
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"/>
19+
<PackageVersion Include="GraphQL.Client" Version="6.1.0" />
20+
<PackageVersion Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.1.0" />
21+
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" />
2022
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
2123
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
2224
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
using Fetcharr.Cache.Core;
2+
using Fetcharr.Models.Configuration;
3+
using Fetcharr.Provider.Plex.Models;
4+
using Fetcharr.Shared.GraphQL;
5+
6+
using GraphQL;
7+
using GraphQL.Client.Http;
8+
using GraphQL.Client.Serializer.SystemTextJson;
9+
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Options;
12+
13+
namespace Fetcharr.Provider.Plex.Clients
14+
{
15+
/// <summary>
16+
/// Client for interacting with Plex' GraphQL API.
17+
/// </summary>
18+
public class PlexGraphQLClient(
19+
IOptions<FetcharrConfiguration> configuration,
20+
[FromKeyedServices("plex-graphql")] ICachingProvider cachingProvider)
21+
{
22+
/// <summary>
23+
/// Gets the GraphQL endpoint for Plex.
24+
/// </summary>
25+
public const string GraphQLEndpoint = "https://community.plex.tv/api";
26+
27+
private readonly GraphQLHttpClient _client =
28+
new GraphQLHttpClient(PlexGraphQLClient.GraphQLEndpoint, new SystemTextJsonSerializer())
29+
.WithAutomaticPersistedQueries(_ => true)
30+
.WithHeader("X-Plex-Token", configuration.Value.Plex.ApiToken)
31+
.WithHeader("X-Plex-Client-Identifier", "fetcharr");
32+
33+
/// <summary>
34+
/// Gets the watchlist of a Plex account, who's a friend of the current plex account.
35+
/// </summary>
36+
public async Task<IEnumerable<WatchlistMetadataItem>> GetFriendWatchlistAsync(
37+
string userId,
38+
int count = 100,
39+
string? cursor = null)
40+
{
41+
string cacheKey = $"friend-watchlist-{userId}";
42+
43+
CacheValue<IEnumerable<WatchlistMetadataItem>> cachedResponse = await cachingProvider
44+
.GetAsync<IEnumerable<WatchlistMetadataItem>>(cacheKey);
45+
46+
if(cachedResponse.HasValue)
47+
{
48+
return cachedResponse.Value;
49+
}
50+
51+
GraphQLRequest request = new()
52+
{
53+
Query = """
54+
query GetFriendWatchlist($uuid: ID = "", $first: PaginationInt!, $after: String) {
55+
user(id: $uuid) {
56+
watchlist(first: $first, after: $after) {
57+
nodes {
58+
... on MetadataItem {
59+
title
60+
ratingKey: id
61+
year
62+
type
63+
}
64+
}
65+
pageInfo {
66+
hasNextPage
67+
endCursor
68+
}
69+
}
70+
}
71+
}
72+
""",
73+
OperationName = "GetFriendWatchlist",
74+
Variables = new
75+
{
76+
uuid = userId,
77+
first = count,
78+
after = cursor ?? string.Empty
79+
}
80+
};
81+
82+
GraphQLResponse<PlexUserWatchlistResponseType> response = await this._client
83+
.SendQueryAsync<PlexUserWatchlistResponseType>(request);
84+
85+
response.ThrowIfErrors(message: "Failed to fetch friend's watchlist from Plex");
86+
87+
IEnumerable<WatchlistMetadataItem> watchlistItems = response.Data.User.Watchlist.Nodes;
88+
89+
await cachingProvider.SetAsync(cacheKey, watchlistItems, expiration: TimeSpan.FromHours(4));
90+
return watchlistItems;
91+
}
92+
93+
/// <summary>
94+
/// Gets all the friends of the current Plex account and returns them.
95+
/// </summary>
96+
public async Task<IEnumerable<PlexFriendUser>> GetAllFriendsAsync()
97+
{
98+
CacheValue<IEnumerable<PlexFriendUser>> cachedResponse = await cachingProvider
99+
.GetAsync<IEnumerable<PlexFriendUser>>("friends-list");
100+
101+
if(cachedResponse.HasValue)
102+
{
103+
return cachedResponse.Value;
104+
}
105+
106+
GraphQLRequest request = new()
107+
{
108+
Query = """
109+
query {
110+
allFriendsV2 {
111+
user {
112+
id
113+
username
114+
}
115+
}
116+
}
117+
"""
118+
};
119+
120+
GraphQLResponse<PlexFriendListResponseType> response = await this._client
121+
.SendQueryAsync<PlexFriendListResponseType>(request);
122+
123+
response.ThrowIfErrors(message: "Failed to fetch friends list from Plex");
124+
125+
IEnumerable<PlexFriendUser> friends = response.Data.Friends.Select(v => v.User);
126+
127+
await cachingProvider.SetAsync("friends-list", friends, expiration: TimeSpan.FromHours(4));
128+
return friends;
129+
}
130+
}
131+
}

src/Provider.Plex/src/Extensions/IServiceCollectionExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Fetcharr.Provider.Plex.Clients;
2+
13
using Microsoft.Extensions.DependencyInjection;
24

35
namespace Fetcharr.Provider.Plex.Extensions
@@ -12,6 +14,9 @@ public static IServiceCollection AddPlexClient(this IServiceCollection services)
1214
services.AddSingleton<PlexClient>();
1315
services.AddSingleton<PlexMetadataClient>();
1416
services.AddSingleton<PlexWatchlistClient>();
17+
services.AddSingleton<PlexFriendsWatchlistClient>();
18+
19+
services.AddSingleton<PlexGraphQLClient>();
1520

1621
return services;
1722
}

src/Provider.Plex/src/Fetcharr.Provider.Plex.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,9 @@
1414
<ProjectReference Include="..\..\Models\src\Fetcharr.Models.csproj" />
1515
</ItemGroup>
1616

17+
<ItemGroup>
18+
<PackageReference Include="GraphQL.Client" />
19+
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" />
20+
</ItemGroup>
21+
1722
</Project>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Fetcharr.Provider.Plex.Models
4+
{
5+
public class PlexFriendListResponseType
6+
{
7+
[JsonPropertyName("allFriendsV2")]
8+
public List<PlexFriendUserContainer> Friends { get; set; } = [];
9+
}
10+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Fetcharr.Provider.Plex.Models
4+
{
5+
/// <summary>
6+
/// Representation of a friend user account.
7+
/// </summary>
8+
public class PlexFriendUser
9+
{
10+
[JsonPropertyName("id")]
11+
public string Id { get; set; } = string.Empty;
12+
13+
[JsonPropertyName("username")]
14+
public string Username { get; set; } = string.Empty;
15+
}
16+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Fetcharr.Provider.Plex.Models
4+
{
5+
/// <summary>
6+
/// Representation of a friend user account container.
7+
/// </summary>
8+
public class PlexFriendUserContainer
9+
{
10+
[JsonPropertyName("user")]
11+
public PlexFriendUser User { get; set; } = new();
12+
}
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Fetcharr.Provider.Plex.Models
4+
{
5+
public class PlexUserWatchlistResponseType
6+
{
7+
[JsonPropertyName("user")]
8+
public PlexWatchlistResponseType User { get; set; } = new();
9+
}
10+
11+
public class PlexWatchlistResponseType
12+
{
13+
[JsonPropertyName("watchlist")]
14+
public PaginatedResult<WatchlistMetadataItem> Watchlist { get; set; } = new();
15+
}
16+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Fetcharr.Provider.Plex.Models
4+
{
5+
public class PaginatedResult<T>
6+
{
7+
[JsonPropertyName("nodes")]
8+
public List<T> Nodes { get; set; } = [];
9+
}
10+
}

0 commit comments

Comments
 (0)