mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
265 lines
8.8 KiB
C#
265 lines
8.8 KiB
C#
using System.Text.Json;
|
|
using allstarr.Models.Scrobbling;
|
|
using allstarr.Services.Jellyfin;
|
|
using allstarr.Services.Local;
|
|
|
|
namespace allstarr.Services.Scrobbling;
|
|
|
|
/// <summary>
|
|
/// Helper methods for extracting scrobble track information from Jellyfin items.
|
|
/// </summary>
|
|
public class ScrobblingHelper
|
|
{
|
|
private readonly JellyfinProxyService _proxyService;
|
|
private readonly ILocalLibraryService _localLibraryService;
|
|
private readonly ILogger<ScrobblingHelper> _logger;
|
|
|
|
public ScrobblingHelper(
|
|
JellyfinProxyService proxyService,
|
|
ILocalLibraryService localLibraryService,
|
|
ILogger<ScrobblingHelper> logger)
|
|
{
|
|
_proxyService = proxyService;
|
|
_localLibraryService = localLibraryService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts scrobble track information from a Jellyfin item.
|
|
/// Fetches item details from Jellyfin if needed.
|
|
/// </summary>
|
|
public async Task<ScrobbleTrack?> GetScrobbleTrackFromItemIdAsync(
|
|
string itemId,
|
|
Microsoft.AspNetCore.Http.IHeaderDictionary headers,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
// Fetch item details from Jellyfin
|
|
var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, headers);
|
|
|
|
if (itemResult == null || statusCode != 200)
|
|
{
|
|
_logger.LogWarning("Failed to fetch item details for scrobbling: {ItemId} (status: {StatusCode})",
|
|
itemId, statusCode);
|
|
return null;
|
|
}
|
|
|
|
return ExtractScrobbleTrackFromJson(itemResult);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching item for scrobbling: {ItemId}", itemId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts scrobble track information from a Jellyfin JSON item.
|
|
/// </summary>
|
|
public ScrobbleTrack? ExtractScrobbleTrackFromJson(JsonDocument itemJson)
|
|
{
|
|
try
|
|
{
|
|
var item = itemJson.RootElement;
|
|
|
|
// Extract required fields
|
|
var title = item.TryGetProperty("Name", out var nameProp) ? nameProp.GetString() : null;
|
|
var artist = ExtractArtist(item);
|
|
|
|
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
|
{
|
|
_logger.LogDebug("Cannot create scrobble track - missing title or artist");
|
|
return null;
|
|
}
|
|
|
|
// Extract optional fields
|
|
var album = item.TryGetProperty("Album", out var albumProp) ? albumProp.GetString() : null;
|
|
var albumArtist = ExtractAlbumArtist(item);
|
|
var durationSeconds = ExtractDurationSeconds(item);
|
|
var musicBrainzId = ExtractMusicBrainzId(item);
|
|
|
|
// Determine if track is external by checking the Path
|
|
var isExternal = false;
|
|
if (item.TryGetProperty("Path", out var pathProp))
|
|
{
|
|
var path = pathProp.GetString();
|
|
if (!string.IsNullOrEmpty(path))
|
|
{
|
|
// External tracks have paths like: ext-deezer-song-123456
|
|
// or are in the "kept" directory for favorited external tracks
|
|
isExternal = path.StartsWith("ext-") || path.Contains("/kept/") || path.Contains("\\kept\\");
|
|
}
|
|
}
|
|
|
|
return new ScrobbleTrack
|
|
{
|
|
Title = title,
|
|
Artist = artist,
|
|
Album = album,
|
|
AlbumArtist = albumArtist,
|
|
DurationSeconds = durationSeconds,
|
|
MusicBrainzId = musicBrainzId,
|
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
IsExternal = isExternal,
|
|
StartPositionSeconds = 0
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error extracting scrobble track from JSON");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a scrobble track from external track metadata.
|
|
/// </summary>
|
|
public ScrobbleTrack? CreateScrobbleTrackFromExternal(
|
|
string title,
|
|
string artist,
|
|
string? album = null,
|
|
string? albumArtist = null,
|
|
int? durationSeconds = null,
|
|
int? startPositionSeconds = null)
|
|
{
|
|
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ScrobbleTrack
|
|
{
|
|
Title = title,
|
|
Artist = artist,
|
|
Album = album,
|
|
AlbumArtist = albumArtist,
|
|
DurationSeconds = durationSeconds,
|
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
IsExternal = true, // Explicitly mark as external
|
|
StartPositionSeconds = startPositionSeconds
|
|
};
|
|
}
|
|
|
|
#region Private Helper Methods
|
|
|
|
/// <summary>
|
|
/// Checks if a track is long enough to be scrobbled according to Last.fm rules.
|
|
/// Tracks must be at least 30 seconds long.
|
|
/// </summary>
|
|
public static bool IsTrackLongEnoughToScrobble(int durationSeconds)
|
|
{
|
|
return durationSeconds >= 30;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if enough of the track has been listened to for scrobbling.
|
|
/// Last.fm rules: Must listen to at least 50% of track OR 4 minutes (whichever comes first).
|
|
/// </summary>
|
|
public static bool HasListenedEnoughToScrobble(int trackDurationSeconds, int playedSeconds)
|
|
{
|
|
var halfDuration = trackDurationSeconds / 2.0;
|
|
var fourMinutes = 240;
|
|
var threshold = Math.Min(halfDuration, fourMinutes);
|
|
return playedSeconds >= threshold;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a track has the minimum required metadata for scrobbling.
|
|
/// Requires at minimum: track title and artist name.
|
|
/// </summary>
|
|
public static bool HasRequiredMetadata(string? title, string? artist)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(artist);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats a track for display in logs.
|
|
/// </summary>
|
|
public static string FormatTrackForDisplay(string title, string artist)
|
|
{
|
|
return $"{title} - {artist}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts artist name from Jellyfin item.
|
|
/// Tries Artists array first, then AlbumArtist, then ArtistItems.
|
|
/// </summary>
|
|
private string? ExtractArtist(JsonElement item)
|
|
{
|
|
// Try Artists array (most common)
|
|
if (item.TryGetProperty("Artists", out var artistsProp) && artistsProp.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var firstArtist = artistsProp.EnumerateArray().FirstOrDefault();
|
|
if (firstArtist.ValueKind == JsonValueKind.String)
|
|
{
|
|
return firstArtist.GetString();
|
|
}
|
|
}
|
|
|
|
// Try AlbumArtist
|
|
if (item.TryGetProperty("AlbumArtist", out var albumArtistProp))
|
|
{
|
|
return albumArtistProp.GetString();
|
|
}
|
|
|
|
// Try ArtistItems array
|
|
if (item.TryGetProperty("ArtistItems", out var artistItemsProp) && artistItemsProp.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var firstArtistItem = artistItemsProp.EnumerateArray().FirstOrDefault();
|
|
if (firstArtistItem.TryGetProperty("Name", out var artistNameProp))
|
|
{
|
|
return artistNameProp.GetString();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts album artist from Jellyfin item.
|
|
/// </summary>
|
|
private string? ExtractAlbumArtist(JsonElement item)
|
|
{
|
|
if (item.TryGetProperty("AlbumArtist", out var albumArtistProp))
|
|
{
|
|
return albumArtistProp.GetString();
|
|
}
|
|
|
|
// Fall back to first artist
|
|
return ExtractArtist(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts track duration in seconds from Jellyfin item.
|
|
/// </summary>
|
|
private int? ExtractDurationSeconds(JsonElement item)
|
|
{
|
|
if (item.TryGetProperty("RunTimeTicks", out var ticksProp))
|
|
{
|
|
var ticks = ticksProp.GetInt64();
|
|
return (int)(ticks / TimeSpan.TicksPerSecond);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts MusicBrainz Track ID from Jellyfin item.
|
|
/// </summary>
|
|
private string? ExtractMusicBrainzId(JsonElement item)
|
|
{
|
|
if (item.TryGetProperty("ProviderIds", out var providerIdsProp))
|
|
{
|
|
if (providerIdsProp.TryGetProperty("MusicBrainzTrack", out var mbidProp))
|
|
{
|
|
return mbidProp.GetString();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
#endregion
|
|
}
|