c17d62fc62
IServerEntryPoint was removed in favor of IHostedService; migrated PlaybackScrobbler and updated PluginServiceRegistrator accordingly (RegisterServices now takes IServerApplicationHost, AnilistApi moved to a singleton so the hosted service can depend on it). Bumped TargetFramework to net9.0 and targetAbi to 10.11.0.0. Also fixed Endpoints.cs, which referenced an "DefaultAuthorization" authorization policy that no longer resolves for plugin controllers on 10.11, causing every API call to 500 instead of 401'ing when unauthenticated. Verified end-to-end against a live 10.11.11 server: plugin loads, OAuth re-auth works, and a real scrobble to Anilist succeeded. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
272 lines
11 KiB
C#
272 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Data.Enums;
|
|
using Jellyfin.Plugin.AnilistSync.API;
|
|
using Jellyfin.Plugin.AnilistSync.API.Exceptions;
|
|
using Jellyfin.Plugin.AnilistSync.Configuration;
|
|
using MediaBrowser.Controller.Entities.Movies;
|
|
using MediaBrowser.Controller.Entities.TV;
|
|
using MediaBrowser.Model.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.Session;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.AnilistSync.Services
|
|
{
|
|
/// <summary>
|
|
/// Playback progress scrobbler.
|
|
/// </summary>
|
|
public class PlaybackScrobbler : IHostedService
|
|
{
|
|
private readonly ISessionManager _sessionManager; // Needed to set up the startPlayBack and endPlayBack functions
|
|
private readonly ILogger<PlaybackScrobbler> _logger;
|
|
private readonly Dictionary<string, Guid> _lastScrobbled; // Library ID of last scrobbled item
|
|
private readonly AnilistApi _anilistApi;
|
|
private DateTime _nextTry;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="PlaybackScrobbler"/> class.
|
|
/// </summary>
|
|
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
|
/// <param name="logger">Instance of the <see cref="ILogger{PlaybackScrobbler}"/> interface.</param>
|
|
/// <param name="anilistApi">Instance of the <see cref="AnilistApi"/>.</param>
|
|
public PlaybackScrobbler(ISessionManager sessionManager, ILogger<PlaybackScrobbler> logger, AnilistApi anilistApi)
|
|
{
|
|
_sessionManager = sessionManager;
|
|
_logger = logger;
|
|
_anilistApi = anilistApi;
|
|
_lastScrobbled = new Dictionary<string, Guid>();
|
|
_nextTry = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_sessionManager.PlaybackProgress += OnPlaybackProgress;
|
|
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// UserConfig config,
|
|
private static bool CanBeScrobbled(UserConfig userConfig, PlaybackProgressEventArgs playbackProgress)
|
|
{
|
|
var position = playbackProgress.PlaybackPositionTicks;
|
|
var runtime = playbackProgress.MediaInfo.RunTimeTicks;
|
|
|
|
if (runtime != null)
|
|
{
|
|
var percentageWatched = position / (float)runtime * 100f;
|
|
|
|
// Check if percentageWatched is greater than threshold
|
|
if (percentageWatched < userConfig.ScrobblePercentage)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Checks if runtime is greater than min length to be scrobbled
|
|
if (runtime < 60 * 10000 * userConfig.MinLength)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Check if movie or episode then check against user config
|
|
return playbackProgress.MediaInfo.Type switch
|
|
{
|
|
BaseItemKind.Movie => userConfig.ScrobbleMovies,
|
|
BaseItemKind.Episode => userConfig.ScrobbleShows,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private async void OnPlaybackProgress(object? sessions, PlaybackProgressEventArgs eventArgs)
|
|
{
|
|
if (DateTime.UtcNow < _nextTry)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Scrobble every 30s
|
|
_nextTry = DateTime.UtcNow.AddSeconds(30);
|
|
await ScrobbleSession(eventArgs);
|
|
}
|
|
|
|
private async void OnPlaybackStopped(object? sessions, PlaybackStopEventArgs eventArgs)
|
|
{
|
|
await ScrobbleSession(eventArgs);
|
|
}
|
|
|
|
private static string? GetAnilistId(PlaybackProgressEventArgs eventArgs)
|
|
{
|
|
string? id = null;
|
|
if (eventArgs.Item is Episode episode)
|
|
{
|
|
id = episode.Series.GetProviderId("AniList");
|
|
}
|
|
else if (eventArgs.Item is Movie movie)
|
|
{
|
|
id = movie.GetProviderId("AniList");
|
|
}
|
|
return id;
|
|
}
|
|
|
|
private async Task ScrobbleSession(PlaybackProgressEventArgs eventArgs)
|
|
{
|
|
try
|
|
{
|
|
var userId = eventArgs.Session.UserId;
|
|
|
|
//Get user config
|
|
var userConfig = Plugin.Instance?.Configuration.GetByGuid(userId);
|
|
|
|
// Check if logged in
|
|
if (userConfig == null || string.IsNullOrEmpty(userConfig.UserToken))
|
|
{
|
|
_logger.LogError(
|
|
"Can't scrobble: User {UserName} not logged in ({UserConfigStatus})",
|
|
eventArgs.Session.UserName,
|
|
userConfig == null);
|
|
return;
|
|
}
|
|
|
|
// Scrobble code
|
|
if (!CanBeScrobbled(userConfig, eventArgs))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if already scrobbled
|
|
if (_lastScrobbled.ContainsKey(eventArgs.Session.Id) && _lastScrobbled[eventArgs.Session.Id] == eventArgs.MediaInfo.Id)
|
|
{
|
|
_logger.LogDebug("Already scrobbled {ItemName} for {UserName}", eventArgs.MediaInfo.Name, eventArgs.Session.UserName);
|
|
return;
|
|
}
|
|
|
|
// Get AniList Id and check if exists in Jellyfin
|
|
string? anilistId = GetAnilistId(eventArgs);
|
|
if (anilistId == null)
|
|
{
|
|
_logger.LogDebug("Cannot Scrobble {ItemName}, unknown AniList Id.", eventArgs.MediaInfo.Name);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Trying to scrobble {Name} ({NowPlayingId}) for {UserName} ({UserId}) - {PlayingItemPath} on {SessionId} - AniList ID {AnilistId}",
|
|
eventArgs.MediaInfo.Name,
|
|
eventArgs.MediaInfo.Id,
|
|
eventArgs.Session.UserName,
|
|
userId,
|
|
eventArgs.MediaInfo.Path,
|
|
eventArgs.Session.Id,
|
|
anilistId);
|
|
|
|
|
|
// Get the list entry for current watching show from Anilist
|
|
var listEntry = _anilistApi.GetListEntry(userConfig.UserToken, Int32.Parse(anilistId)).Result?.Data?.ListEntry;
|
|
|
|
int? currentIndex = eventArgs.Item.IndexNumber;
|
|
int? currentRemoteIndex = listEntry?.Progress;
|
|
MediaListStatus? status = listEntry?.Status;
|
|
|
|
switch (status)
|
|
{
|
|
case MediaListStatus.COMPLETED: // Check if STARTING a rewatch
|
|
if (userConfig.ScrobbleRewatches)
|
|
{
|
|
if (currentIndex == 1) // Only initialize a rewatch if watching first episode
|
|
{
|
|
status = MediaListStatus.REPEATING;
|
|
await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status);
|
|
_logger.LogInformation("Rewatch started");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("Attempting to start rewatch from middle episode, discarding scrobble");
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("User has chosen not to scrobble rewatches");
|
|
return;
|
|
}
|
|
break;
|
|
case MediaListStatus.REPEATING:
|
|
if (userConfig.ScrobbleRewatches)
|
|
{
|
|
if (currentIndex <= currentRemoteIndex)
|
|
{
|
|
_logger.LogInformation("Episode number <= Anilist episode watch count, discarding scrobble");
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("User has chosen not to scrobble rewatches");
|
|
return;
|
|
}
|
|
break;
|
|
case MediaListStatus.PLANNING:
|
|
case MediaListStatus.DROPPED:
|
|
case MediaListStatus.PAUSED:
|
|
status = MediaListStatus.CURRENT;
|
|
await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status);
|
|
break;
|
|
case MediaListStatus.CURRENT:
|
|
if (currentIndex <= currentRemoteIndex)
|
|
{
|
|
_logger.LogInformation("Episode number <= Anilist episode watch count, discarding scrobble");
|
|
return;
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
// Get total number of episodes of current item from Anilist
|
|
int? totalEpisodes = (await _anilistApi.GetEpisodes(Int32.Parse(anilistId)))?.Data?.Media?.Episodes;
|
|
|
|
// If watching LAST episode change status to completed
|
|
if (currentIndex == totalEpisodes)
|
|
{
|
|
status = MediaListStatus.COMPLETED;
|
|
await _anilistApi.PostListStatusUpdate(userConfig.UserToken, listEntry?.Id, status);
|
|
}
|
|
else
|
|
{
|
|
await _anilistApi.PostListProgressUpdate(userConfig.UserToken, listEntry?.Id, currentIndex);
|
|
}
|
|
_logger.LogInformation("Scrobbled episode: ({currentIndex} of {totalEpisodes}) for Anilist ID: ({anilistId})", currentIndex, totalEpisodes, anilistId);
|
|
_logger.LogInformation("Watch status: {status}", status);
|
|
_logger.LogInformation("Scrobbled without errors");
|
|
_lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id;
|
|
|
|
}
|
|
catch (AnilistAPIException aniAPIEx)
|
|
{
|
|
_logger.LogError(aniAPIEx, "Error status: {aniAPIEx.statusCode}", aniAPIEx.statusCode);
|
|
}
|
|
catch (InvalidDataException ex)
|
|
{
|
|
_logger.LogError(ex, "Couldn't scrobble");
|
|
_lastScrobbled[eventArgs.Session.Id] = eventArgs.MediaInfo.Id;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Caught unknown exception while trying to scrobble");
|
|
}
|
|
}
|
|
}
|
|
} |