using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Kavita.API.Services;
using Kavita.API.Services.SignalR;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Extensions;
using Kavita.Models.DTOs.SignalR;
using Kavita.Models.DTOs.Update;
using Kavita.Services.Extensions;
using Markdig;
using Microsoft.Extensions.Logging;
using Markdown = Markdig.Markdown;
namespace Kavita.Services;
internal class GithubReleaseMetadata
{
///
/// Name of the Tag
/// v0.4.3
///
// ReSharper disable once InconsistentNaming
public required string Tag_Name { get; init; }
///
/// Name of the Release
///
public required string Name { get; init; }
///
/// Body of the Release
///
public required string Body { get; init; }
///
/// Url of the release on GitHub
///
// ReSharper disable once InconsistentNaming
public required string Html_Url { get; init; }
///
/// Date Release was Published
///
// ReSharper disable once InconsistentNaming
public required string Published_At { get; init; }
}
public partial class VersionUpdaterService : IVersionUpdaterService
{
private readonly ILogger _logger;
private readonly IEventHub _eventHub;
private readonly IGithubApiService _githubApiService;
private readonly IFileCacheService _fileCacheService;
private readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder().UseGithub().Build();
private const string KavitaRepo = "Kareadita/Kavita";
private const string ReleasesPath = $"repos/{KavitaRepo}/releases";
private const string PullsPath = $"repos/{KavitaRepo}/pulls/";
private const string DevelopCommitsPath = $"repos/{KavitaRepo}/commits?sha=develop";
private const string ReleasesCacheKey = "releases";
[GeneratedRegex(@"^\n*([\s\S]*?)\n+^#\s(Added|Fixed|Changed|Theme|API|Removed)", RegexOptions.Multiline | RegexOptions.Compiled)]
private static partial Regex BlogPartRegex();
[GeneratedRegex(@"Release changes for (\d+\.\d+\.\d+\.\d+)", RegexOptions.Multiline | RegexOptions.Compiled, 1_000)]
private static partial Regex NightlyReleaseRegex();
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private static readonly TimeSpan PrCacheDuration = TimeSpan.FromDays(7);
public VersionUpdaterService(ILogger logger, IEventHub eventHub,
IGithubApiService githubApiService, IFileCacheService fileCacheService)
{
_logger = logger;
_eventHub = eventHub;
_githubApiService = githubApiService;
_fileCacheService = fileCacheService;
}
///
/// Returns the latest stable release, or null when GitHub cannot be reached and nothing is cached
///
/// Ask GitHub even when the cache is fresh, for a release published within the hour
///
/// Latest update
public async Task CheckForUpdate(bool forceRefresh = false, CancellationToken ct = default)
{
var releases = await GetReleases(forceRefresh, ct);
return releases.FirstOrDefault(r => !r.IsPrerelease);
}
///
/// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable.
///
///
///
///
private async Task EnrichWithNightlyInfo(List dtos, bool forceRefresh, CancellationToken ct)
{
var dto = dtos[0]; // Latest version
try
{
var currentVersion = new Version(dto.CurrentVersion);
var nightlyReleases = await GetNightlyReleases(currentVersion, Version.Parse(dto.UpdateVersion), forceRefresh, ct);
if (nightlyReleases.Count == 0) return;
// Create new DTOs for each nightly release and insert them at the beginning of the list
var nightlyDtos = new List();
foreach (var nightly in nightlyReleases)
{
var prInfo = (await _githubApiService.GetAsync($"{PullsPath}{nightly.PrNumber}", PrCacheDuration, ct: ct)).Value;
if (prInfo == null) continue;
var sections = ParseReleaseBody(prInfo.Body);
var blogPart = ExtractBlogPart(prInfo.Body);
var nightlyDto = new UpdateNotificationDto
{
// TODO: I should pass Title to the FE so that Nightly Release can be localized
UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}",
UpdateVersion = nightly.Version,
CurrentVersion = dto.CurrentVersion,
UpdateUrl = prInfo.Html_Url,
PublishDate = prInfo.Merged_At,
IsDocker = true, // Nightlies are always Docker Only
IsReleaseEqual = IsVersionEqualToBuildVersion(Version.Parse(nightly.Version)),
IsReleaseNewer = true, // Since we already filtered these in GetNightlyReleases
IsPrerelease = true, // All Nightlies are considered prerelease
Added = sections.TryGetValue("Added", out var added) ? added : [],
Changed = sections.TryGetValue("Changed", out var changed) ? changed : [],
Fixed = sections.TryGetValue("Fixed", out var bugfixes) ? bugfixes : [],
Removed = sections.TryGetValue("Removed", out var removed) ? removed : [],
Theme = sections.TryGetValue("Theme", out var theme) ? theme : [],
Developer = sections.TryGetValue("Developer", out var developer) ? developer : [],
KnownIssues = sections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [],
Api = sections.TryGetValue("Api", out var api) ? api : [],
FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [],
BlogPart = Markdown.ToHtml(blogPart.Trim(), _markdownPipeline),
UpdateBody = Markdown.ToHtml(prInfo.Body.Trim(), _markdownPipeline)
};
nightlyDtos.Add(nightlyDto);
}
// Insert nightly releases at the beginning of the list
var sortedNightlyDtos = nightlyDtos.OrderByDescending(x => x.PublishDate).ToList();
dtos.InsertRange(0, sortedNightlyDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enrich nightly release information");
}
}
private async Task> GetNightlyReleases(Version currentVersion, Version latestStableVersion,
bool forceRefresh, CancellationToken ct)
{
try
{
var nightlyReleases = new List();
var commits = await _githubApiService.GetAsync>(DevelopCommitsPath, CacheDuration, forceRefresh, ct);
if (!commits.HasValue) return [];
var commitList = commits.Value;
var foundLastStable = false;
for (var i = 0; i < commitList.Count - 1; i++)
{
var commit = commitList[i];
var message = commit.Commit.Message.Split('\n')[0]; // Take first line only
// Skip [skip ci] commits
if (message.Contains("[skip ci]")) continue;
// Check if this is a stable release
if (message.StartsWith('v'))
{
var stableMatch = Regex.Match(message, @"v(\d+\.\d+\.\d+\.\d+)");
if (stableMatch.Success)
{
var stableVersion = new Version(stableMatch.Groups[1].Value);
// If we find a stable version lower than current, we've gone too far back
if (stableVersion <= currentVersion)
{
foundLastStable = true;
break;
}
}
continue;
}
// Look for version bumps that follow PRs
if (!foundLastStable && message == "Bump versions by dotnet-bump-version.")
{
// Get the PR commit that triggered this version bump
if (i + 1 < commitList.Count)
{
var prCommit = commitList[i + 1];
var prNumber = GetPrNumber(prCommit);
if (prNumber is null) continue;
// Get the version from AssemblyInfo.cs in this commit
var version = await GetVersionFromCommit(commit.Sha, ct);
if (version == null) continue;
// Parse version and compare with current version
if (Version.TryParse(version, out var parsedVersion) &&
parsedVersion > latestStableVersion)
{
nightlyReleases.Add(new NightlyInfo
{
Version = version,
PrNumber = prNumber.Value,
Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture)
});
}
}
}
var newNightlyMatch = NightlyReleaseRegex().Match(message);
if (!foundLastStable && newNightlyMatch.Success && i + 1 < commitList.Count)
{
var prCommit = commitList[i + 1];
var prNumber = GetPrNumber(prCommit);
if (prNumber is null) continue;
var version = newNightlyMatch.Groups[1].Value;
if (Version.TryParse(version, out var parsedVersion) && parsedVersion > latestStableVersion)
{
nightlyReleases.Add(new NightlyInfo
{
Version = version,
PrNumber = prNumber.Value,
Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture)
});
}
}
}
return nightlyReleases.OrderByDescending(x => x.Date).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get nightly releases");
return [];
}
}
private static int? GetPrNumber(CommitInfo commitInfo)
{
var prMessage = commitInfo.Commit.Message.Split('\n')[0];
// Extract PR number using improved regex
var prMatch = Regex.Match(prMessage, @"(?:^|\s)\(#(\d+)\)|\s#(\d+)");
if (!prMatch.Success)
{
return null;
}
var prNumber = prMatch.Groups[1].Value != "" ? prMatch.Groups[1].Value : prMatch.Groups[2].Value;
return int.TryParse(prNumber, out var result) ? result : null;
}
///
/// Returns releases newest first. When GitHub cannot be reached, serves the last cached copy at any age, or an empty list.
///
public async Task> GetAllReleases(int count = 0, CancellationToken ct = default)
{
var releases = await GetReleases(forceRefresh: false, ct);
return count > 0 ? releases.Take(count).ToList() : releases;
}
///
/// The built release list is cached separately from the raw GitHub responses: it depends on this install's version,
/// and building it renders markdown and downloads csproj files for nightlies.
///
private async Task> GetReleases(bool forceRefresh, CancellationToken ct)
{
var result = await _fileCacheService.GetOrFetchResultAsync(ReleasesCacheKey, FileCacheService.VersionDirectory,
CacheDuration,
token => BuildReleases(forceRefresh, token),
shouldCache: releases => releases is { Count: > 0 },
serveStaleOnFailure: true,
forceRefresh: forceRefresh,
ct: ct);
return result.Value ?? [];
}
private async Task?> BuildReleases(bool forceRefresh, CancellationToken ct)
{
var releases = await _githubApiService.GetAsync>(ReleasesPath, CacheDuration, forceRefresh, ct);
var updateDtos = (releases.Value ?? [])
.Select(CreateDto)
.Where(d => d != null)
.OrderByDescending(d => d!.PublishDate)
.Select(d => d!)
.ToList();
if (updateDtos.Count == 0) return null;
// Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github
var versionParts = updateDtos[0].UpdateVersion.Split('.');
if (versionParts.Length < 4)
{
updateDtos[0].UpdateVersion += ".0"; // Append missing parts
}
if (IsBuildNewerThan(new Version(updateDtos[0].UpdateVersion)))
{
await EnrichWithNightlyInfo(updateDtos, forceRefresh, ct);
}
var latestRelease = updateDtos[0];
latestRelease.IsOnNightlyInRelease = IsBuildNewerThan(new Version(latestRelease.UpdateVersion));
return updateDtos;
}
///
/// True when this install is ahead of the given release, i.e. on a nightly. v0.8.1 and v0.8.1.0 count as equal.
///
private static bool IsBuildNewerThan(Version releaseVersion)
{
return BuildInfo.Version > releaseVersion && !IsVersionEqualToBuildVersion(releaseVersion);
}
private static bool IsVersionEqualToBuildVersion(Version updateVersion)
{
return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 &&
BuildInfo.Version.CompareWithoutRevision(updateVersion));
}
///
/// Returns the number of releases ahead of this install version. If this install version is on a nightly,
/// then include nightly releases, otherwise only count Stable releases.
///
/// Only count Stable releases
///
///
public async Task GetNumberOfReleasesBehind(bool stableOnly = false, CancellationToken ct = default)
{
var updates = await GetAllReleases(ct: ct);
if (updates.Count == 0) return 0;
// If the user is on nightly, then we need to handle releases behind differently
if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease))
{
return updates.Count(u => u.IsReleaseNewer);
}
return updates
.Where(update => !update.IsPrerelease)
.Count(u => u.IsReleaseNewer);
}
///
/// Clears the built release list and the cached GitHub responses
///
///
public void BustGithubCache(CancellationToken ct = default)
{
_fileCacheService.Invalidate(ReleasesCacheKey, FileCacheService.VersionDirectory);
// A /releases response cached before this version was published would make this build look like a nightly
_githubApiService.ClearCache();
}
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
{
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
if (!Version.TryParse(update.Tag_Name.Replace("v", string.Empty), out var updateVersion))
{
_logger.LogWarning("Skipping GitHub release with an unparseable tag {Tag}", update.Tag_Name);
return null;
}
var currentVersion = BuildInfo.Version.ToString(4);
var bodyHtml = Markdown.ToHtml(update.Body.Trim(), _markdownPipeline);
var parsedSections = ParseReleaseBody(update.Body);
var blogPart = Markdown.ToHtml(ExtractBlogPart(update.Body).Trim(), _markdownPipeline);
return new UpdateNotificationDto()
{
CurrentVersion = currentVersion,
UpdateVersion = updateVersion.ToString(),
UpdateBody = bodyHtml,
UpdateTitle = update.Name,
UpdateUrl = update.Html_Url,
IsDocker = OsInfo.IsDocker,
PublishDate = update.Published_At,
IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion),
IsReleaseNewer = BuildInfo.Version < updateVersion,
IsPrerelease = false,
Added = parsedSections.TryGetValue("Added", out var added) ? added : [],
Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [],
Changed = parsedSections.TryGetValue("Changed", out var changed) ? changed : [],
Fixed = parsedSections.TryGetValue("Fixed", out var fixes) ? fixes : [],
Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [],
Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [],
KnownIssues = parsedSections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [],
Api = parsedSections.TryGetValue("Api", out var api) ? api : [],
FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [],
BlogPart = blogPart
};
}
public async Task PushUpdate(UpdateNotificationDto update, CancellationToken ct = default)
{
if (update == null) return;
var updateVersion = new Version(update.UpdateVersion);
if (BuildInfo.Version < updateVersion)
{
_logger.LogWarning("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update),
true, ct);
}
}
private async Task GetVersionFromCommit(string commitSha, CancellationToken ct)
{
// Pinned to a commit sha, so the file never changes
var csproj = await _githubApiService.DownloadRawAsync(KavitaRepo, commitSha, "Kavita.Common/Kavita.Common.csproj",
TimeSpan.MaxValue, ct);
if (!csproj.HasValue) return null;
var versionMatch = Regex.Match(csproj.Value, @"([0-9\.]+)");
return versionMatch.Success ? versionMatch.Groups[1].Value : null;
}
private static string ExtractBlogPart(string body)
{
if (body.StartsWith('#')) return string.Empty;
var match = BlogPartRegex().Match(body);
return match.Success ? match.Groups[1].Value.Trim() : body.Trim();
}
private static Dictionary> ParseReleaseBody(string body)
{
var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase);
var lines = body.Split('\n');
string? currentSection = null;
foreach (var line in lines)
{
var trimmedLine = line.Trim();
// Check for section headers (case-insensitive)
if (trimmedLine.StartsWith('#'))
{
currentSection = trimmedLine.TrimStart('#').Trim();
sections[currentSection] = [];
continue;
}
// Parse items under a section
if (currentSection != null &&
trimmedLine.StartsWith("- ") &&
!string.IsNullOrWhiteSpace(trimmedLine))
{
// Remove "Fixed:", "Added:" etc. if present
var cleanedItem = CleanSectionItem(trimmedLine);
// Some sections like API/Developer/Removed don't have the title repeated, so we need to check for an additional cleaning
if (cleanedItem.StartsWith("- "))
{
cleanedItem = trimmedLine.Substring(2);
}
// Only add non-empty items
if (!string.IsNullOrWhiteSpace(cleanedItem))
{
sections[currentSection].Add(cleanedItem);
}
}
}
return sections;
}
private static string CleanSectionItem(string item)
{
// Remove everything up to and including the first ":"
var colonIndex = item.IndexOf(':');
if (colonIndex != -1)
{
item = item.Substring(colonIndex + 1).Trim();
}
return item;
}
private sealed class PullRequestInfo
{
public required string Title { get; init; }
public required string Body { get; init; }
public required string Html_Url { get; init; }
public required string Merged_At { get; init; }
public required int Number { get; init; }
}
private sealed class CommitInfo
{
public required string Sha { get; init; }
public required CommitDetail Commit { get; init; }
public required string Html_Url { get; init; }
}
private sealed class CommitDetail
{
public required string Message { get; init; }
public required CommitAuthor Author { get; init; }
}
private sealed class CommitAuthor
{
public required string Date { get; init; }
}
private sealed class NightlyInfo
{
public required string Version { get; init; }
public required int PrNumber { get; init; }
public required DateTime Date { get; init; }
}
}