diff --git a/src/Sonarr.Api.V5/Calendar/CalendarController.cs b/src/Sonarr.Api.V5/Calendar/CalendarController.cs new file mode 100644 index 000000000..c78310f81 --- /dev/null +++ b/src/Sonarr.Api.V5/Calendar/CalendarController.cs @@ -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 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(); + var result = new List(); + + 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(); + } + } +} diff --git a/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs b/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs new file mode 100644 index 000000000..fe0c672bc --- /dev/null +++ b/src/Sonarr.Api.V5/Calendar/CalendarFeedController.cs @@ -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(); + + 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(); + occurrence.Uid = "NzbDrone_episode_" + episode.Id; + occurrence.Status = episode.HasFile ? EventStatus.Confirmed : EventStatus.Tentative; + occurrence.Description = episode.Overview; + occurrence.Categories = new List() { 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"); + } +} diff --git a/src/Sonarr.Api.V5/Episodes/EpisodeControllerWithSignalR.cs b/src/Sonarr.Api.V5/Episodes/EpisodeControllerWithSignalR.cs new file mode 100644 index 000000000..92c9138ca --- /dev/null +++ b/src/Sonarr.Api.V5/Episodes/EpisodeControllerWithSignalR.cs @@ -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, + IHandle, + IHandle, + IHandle +{ + 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 MapToResource(List episodes, bool includeSeries, bool includeEpisodeFile, bool includeImages) + { + var result = episodes.ToResource(); + + if (includeSeries || includeEpisodeFile || includeImages) + { + var seriesDict = new Dictionary(); + 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); + } + } +} diff --git a/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs b/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs index a69942009..994b18b99 100644 --- a/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs +++ b/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs @@ -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; }