Add v5 calendar endpoints

This commit is contained in:
Mark McDowall 2025-11-09 21:31:00 -08:00
parent 5867cd5f47
commit 6a3e1278a5
4 changed files with 320 additions and 1 deletions

View file

@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Tags;
using NzbDrone.Core.Tv;
using NzbDrone.SignalR;
using Sonarr.Api.V5.Episodes;
using Sonarr.Http;
namespace Sonarr.Api.V5.Calendar
{
[V5ApiController]
public class CalendarController : EpisodeControllerWithSignalR
{
private readonly ITagService _tagService;
public CalendarController(IBroadcastSignalRMessage signalR,
IEpisodeService episodeService,
ISeriesService seriesService,
IUpgradableSpecification qualityUpgradableSpecification,
ITagService tagService,
ICustomFormatCalculationService formatCalculator)
: base(episodeService, seriesService, qualityUpgradableSpecification, formatCalculator, signalR)
{
_tagService = tagService;
}
[HttpGet]
[Produces("application/json")]
public List<EpisodeResource> GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeSeries = false, bool includeEpisodeFile = false, bool includeEpisodeImages = false, string tags = "")
{
var startUse = start ?? DateTime.Today;
var endUse = end ?? DateTime.Today.AddDays(2);
var episodes = _episodeService.EpisodesBetweenDates(startUse, endUse, unmonitored);
var allSeries = _seriesService.GetAllSeries();
var parsedTags = new List<int>();
var result = new List<Episode>();
if (tags.IsNotNullOrWhiteSpace())
{
parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id));
}
foreach (var episode in episodes)
{
var series = allSeries.SingleOrDefault(s => s.Id == episode.SeriesId);
if (series == null)
{
continue;
}
if (parsedTags.Any() && parsedTags.None(series.Tags.Contains))
{
continue;
}
result.Add(episode);
}
var resources = MapToResource(result, includeSeries, includeEpisodeFile, includeEpisodeImages);
return resources.OrderBy(e => e.AirDateUtc).ToList();
}
}
}

View file

@ -0,0 +1,101 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using Ical.Net.Serialization;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Tags;
using NzbDrone.Core.Tv;
using Sonarr.Http;
namespace Sonarr.Api.V5.Calendar;
[V5FeedController("calendar")]
public class CalendarFeedController : Controller
{
private readonly IEpisodeService _episodeService;
private readonly ISeriesService _seriesService;
private readonly ITagService _tagService;
public CalendarFeedController(IEpisodeService episodeService, ISeriesService seriesService, ITagService tagService)
{
_episodeService = episodeService;
_seriesService = seriesService;
_tagService = tagService;
}
[HttpGet("Sonarr.ics")]
public IActionResult GetCalendarFeed(int pastDays = 7, int futureDays = 28, string tags = "", bool unmonitored = false, bool premieresOnly = false, bool asAllDay = false)
{
var start = DateTime.Today.AddDays(-pastDays);
var end = DateTime.Today.AddDays(futureDays);
var parsedTags = new List<int>();
if (tags.IsNotNullOrWhiteSpace())
{
parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id));
}
var episodes = _episodeService.EpisodesBetweenDates(start, end, unmonitored);
var allSeries = _seriesService.GetAllSeries();
var calendar = new Ical.Net.Calendar
{
ProductId = "-//sonarr.tv//Sonarr//EN"
};
var calendarName = "Sonarr TV Schedule";
calendar.AddProperty(new CalendarProperty("NAME", calendarName));
calendar.AddProperty(new CalendarProperty("X-WR-CALNAME", calendarName));
foreach (var episode in episodes.OrderBy(v => v.AirDateUtc!.Value))
{
var series = allSeries.SingleOrDefault(s => s.Id == episode.SeriesId);
if (series == null)
{
continue;
}
if (premieresOnly && (episode.SeasonNumber == 0 || episode.EpisodeNumber != 1))
{
continue;
}
if (parsedTags.Any() && parsedTags.None(series.Tags.Contains))
{
continue;
}
var occurrence = calendar.Create<CalendarEvent>();
occurrence.Uid = "NzbDrone_episode_" + episode.Id;
occurrence.Status = episode.HasFile ? EventStatus.Confirmed : EventStatus.Tentative;
occurrence.Description = episode.Overview;
occurrence.Categories = new List<string>() { series.Network };
if (asAllDay)
{
occurrence.Start = new CalDateTime(episode.AirDateUtc!.Value.ToLocalTime()) { HasTime = false };
}
else
{
occurrence.Start = new CalDateTime(episode.AirDateUtc!.Value) { HasTime = true };
occurrence.End = new CalDateTime(episode.AirDateUtc.Value.AddMinutes(series.Runtime)) { HasTime = true };
}
switch (series.SeriesType)
{
case SeriesTypes.Daily:
occurrence.Summary = $"{series.Title} - {episode.Title}";
break;
default:
occurrence.Summary = $"{series.Title} - {episode.SeasonNumber}x{episode.EpisodeNumber:00} - {episode.Title}";
break;
}
}
var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext());
var icalendar = serializer.SerializeToString(calendar);
return Content(icalendar, "text/calendar");
}
}

View file

@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Download;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Tv;
using NzbDrone.SignalR;
using Sonarr.Api.V5.EpisodeFiles;
using Sonarr.Api.V5.Series;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.Episodes;
public abstract class EpisodeControllerWithSignalR : RestControllerWithSignalR<EpisodeResource, Episode>,
IHandle<EpisodeGrabbedEvent>,
IHandle<EpisodeImportedEvent>,
IHandle<EpisodeFileDeletedEvent>
{
protected readonly IEpisodeService _episodeService;
protected readonly ISeriesService _seriesService;
protected readonly IUpgradableSpecification _upgradableSpecification;
protected readonly ICustomFormatCalculationService _formatCalculator;
protected EpisodeControllerWithSignalR(IEpisodeService episodeService,
ISeriesService seriesService,
IUpgradableSpecification upgradableSpecification,
ICustomFormatCalculationService formatCalculator,
IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
_episodeService = episodeService;
_seriesService = seriesService;
_upgradableSpecification = upgradableSpecification;
_formatCalculator = formatCalculator;
}
protected EpisodeControllerWithSignalR(IEpisodeService episodeService,
ISeriesService seriesService,
IUpgradableSpecification upgradableSpecification,
ICustomFormatCalculationService formatCalculator,
IBroadcastSignalRMessage signalRBroadcaster,
string resource)
: base(signalRBroadcaster)
{
_episodeService = episodeService;
_seriesService = seriesService;
_upgradableSpecification = upgradableSpecification;
_formatCalculator = formatCalculator;
}
protected override EpisodeResource GetResourceById(int id)
{
var episode = _episodeService.GetEpisode(id);
var resource = MapToResource(episode, true, true, true);
return resource;
}
protected EpisodeResource MapToResource(Episode episode, bool includeSeries, bool includeEpisodeFile, bool includeImages)
{
var resource = episode.ToResource();
if (includeSeries || includeEpisodeFile || includeImages)
{
var series = episode.Series ?? _seriesService.GetSeries(episode.SeriesId);
if (includeSeries)
{
resource.Series = series.ToResource();
}
if (includeEpisodeFile && episode.EpisodeFileId != 0)
{
resource.EpisodeFile = episode.EpisodeFile.Value.ToResource(series, _upgradableSpecification, _formatCalculator);
}
if (includeImages)
{
resource.Images = episode.Images;
}
}
return resource;
}
protected List<EpisodeResource> MapToResource(List<Episode> episodes, bool includeSeries, bool includeEpisodeFile, bool includeImages)
{
var result = episodes.ToResource();
if (includeSeries || includeEpisodeFile || includeImages)
{
var seriesDict = new Dictionary<int, NzbDrone.Core.Tv.Series>();
for (var i = 0; i < episodes.Count; i++)
{
var episode = episodes[i];
var resource = result[i];
var series = episode.Series ?? seriesDict.GetValueOrDefault(episodes[i].SeriesId) ?? _seriesService.GetSeries(episodes[i].SeriesId);
seriesDict[series.Id] = series;
if (includeSeries)
{
resource.Series = series.ToResource();
}
if (includeEpisodeFile && episode.EpisodeFileId != 0)
{
resource.EpisodeFile = episode.EpisodeFile.Value.ToResource(series, _upgradableSpecification, _formatCalculator);
}
if (includeImages)
{
resource.Images = episode.Images;
}
}
}
return result;
}
[NonAction]
public void Handle(EpisodeGrabbedEvent message)
{
foreach (var episode in message.Episode.Episodes)
{
var resource = episode.ToResource();
resource.Grabbed = true;
BroadcastResourceChange(ModelAction.Updated, resource);
}
}
[NonAction]
public void Handle(EpisodeImportedEvent message)
{
foreach (var episode in message.EpisodeInfo.Episodes)
{
BroadcastResourceChange(ModelAction.Updated, episode.Id);
}
}
[NonAction]
public void Handle(EpisodeFileDeletedEvent message)
{
foreach (var episode in message.EpisodeFile.Episodes.Value)
{
BroadcastResourceChange(ModelAction.Updated, episode.Id);
}
}
}

View file

@ -15,7 +15,7 @@ public class EpisodeResource : RestResource
public int EpisodeFileId { get; set; }
public int SeasonNumber { get; set; }
public int EpisodeNumber { get; set; }
public required string Title { get; set; }
public string? Title { get; set; }
public string? AirDate { get; set; }
public DateTime? AirDateUtc { get; set; }
public DateTime? LastSearchTime { get; set; }