Skip to content

feat: Add support for syncing watchlist of friends #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@
"api_token": {
"type": "string",
"description": "API token for authenticating with Plex.\nSee more here: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/"
},
"sync_friends_watchlist": {
"type": "boolean",
"default": false,
"description": "Whether to include friends' watchlists when syncing."
}
}
},
Expand Down
10 changes: 9 additions & 1 deletion docs/docs/configuration/basic-setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Plex tokens are not permanent and will expire after some time. Continuously upda
```yaml
plex:
api_token: PLEX_TOKEN
sync_friends_watchlist: false
```

To setup your Plex account in Fetcharr, you need to get your Plex token. While we could outline the steps for getting yours here, it would be better to refer til Plex' official documentation.
Expand All @@ -51,6 +52,13 @@ You can read their page on [getting your Plex token here](https://support.plex.t

:::

### `sync_friends_watchlist`
**Optional.** Default: `false`

Defines whether Fetcharr should also sync the watchlist of friends on Plex. This is useful when you have multiple users, who all have separate accounts instead of managed users, but you still want to fetch their watchlist.

For this to work, your friends may need to set their watchlist visibility in Plex to be either `Friends Only` or `Friends of Friends`. [You can find the setting here](https://app.plex.tv/desktop/#!/settings/account) under `Setting > Account Visibility & Account Sharing > My Watchlist`.

## Service

"Service" is the generic term for both Radarr and Sonarr instances. Most configuration settings are the same, although they may each have extra configuration available.
Expand Down Expand Up @@ -116,7 +124,7 @@ Whether the instance should be enabled. This can be useful for playing around wi

Which filters to apply to the instance. They can help limit what content will be sent to the instance, such as limiting an instance to be anime-only, kids-only, etc.

For more information about filters, click here.
For more information about filters, [click here](./filters.mdx).

### `root_folder`
**Optional.** Default: `null`
Expand Down
4 changes: 4 additions & 0 deletions fetcharr.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ plex:
## If you need help finding yours, see here: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/
api_token: PLEX_TOKEN

## Whether to include friends' watchlists when syncing.
## Default: false
sync_friends_watchlist: false

## List of all Sonarr instances available
sonarr:
default:
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,12 @@ public sealed class FetcharrPlexConfiguration
[Required]
[YamlMember(Alias = "api_token")]
public string ApiToken { get; set; } = string.Empty;

/// <summary>
/// Gets or sets whether to include friends' watchlists in the sync.
/// </summary>
[Required]
[YamlMember(Alias = "sync_friends_watchlist")]
public bool IncludeFriendsWatchlist { get; set; } = false;
}
}
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>
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
Loading