diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js
index 133f2e466..608528478 100644
--- a/frontend/src/Calendar/Agenda/AgendaEvent.js
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -8,6 +8,7 @@ import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@@ -52,6 +53,7 @@ class AgendaEvent extends Component {
airDateUtc,
monitored,
unverifiedSceneNumbering,
+ finaleType,
hasFile,
grabbed,
queueItem,
@@ -71,8 +73,6 @@ class AgendaEvent extends Component {
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
return (
@@ -189,15 +189,14 @@ class AgendaEvent extends Component {
{
showFinaleIcon &&
- episodeNumber !== 1 &&
- seasonNumber > 0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount &&
+ finaleType ?
+ title={getFinaleTypeName(finaleType)}
+ /> :
+ null
}
{
@@ -238,6 +237,7 @@ AgendaEvent.propTypes = {
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
+ finaleType: PropTypes.string,
hasFile: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
index ebb63de7c..122022773 100644
--- a/frontend/src/Calendar/Events/CalendarEvent.js
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -7,6 +7,7 @@ import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@@ -57,6 +58,7 @@ class CalendarEvent extends Component {
airDateUtc,
monitored,
unverifiedSceneNumbering,
+ finaleType,
hasFile,
grabbed,
queueItem,
@@ -79,8 +81,6 @@ class CalendarEvent extends Component {
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
return (
0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount ?
+ finaleType ?
:
null
}
@@ -247,6 +245,7 @@ CalendarEvent.propTypes = {
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
+ finaleType: PropTypes.string,
hasFile: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js
index 633056733..d79b803db 100644
--- a/frontend/src/Calendar/Events/CalendarEventGroup.js
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.js
@@ -6,6 +6,7 @@ import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
+import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
@@ -175,15 +176,13 @@ class CalendarEventGroup extends Component {
{
showFinaleIcon &&
- lastEpisode.episodeNumber !== 1 &&
- seasonNumber > 0 &&
- lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount &&
+ lastEpisode.finaleType ?
+ title={getFinaleTypeName(lastEpisode.finaleType)}
+ /> : null
}
diff --git a/frontend/src/Episode/EpisodeTitleLink.css b/frontend/src/Episode/EpisodeTitleLink.css
index 7fd85c836..8e5d62e33 100644
--- a/frontend/src/Episode/EpisodeTitleLink.css
+++ b/frontend/src/Episode/EpisodeTitleLink.css
@@ -1,3 +1,8 @@
+.container {
+ display: flex;
+ align-items: center;
+}
+
.link {
composes: link from '~Components/Link/Link.css';
diff --git a/frontend/src/Episode/EpisodeTitleLink.css.d.ts b/frontend/src/Episode/EpisodeTitleLink.css.d.ts
index f192f9e8a..1214ecfc1 100644
--- a/frontend/src/Episode/EpisodeTitleLink.css.d.ts
+++ b/frontend/src/Episode/EpisodeTitleLink.css.d.ts
@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
+ 'container': string;
'link': string;
}
export const cssExports: CssExports;
diff --git a/frontend/src/Episode/EpisodeTitleLink.js b/frontend/src/Episode/EpisodeTitleLink.js
deleted file mode 100644
index 824d53eee..000000000
--- a/frontend/src/Episode/EpisodeTitleLink.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Link from 'Components/Link/Link';
-import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
-import styles from './EpisodeTitleLink.css';
-
-class EpisodeTitleLink extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isDetailsModalOpen: false
- };
- }
-
- //
- // Listeners
-
- onLinkPress = () => {
- this.setState({ isDetailsModalOpen: true });
- };
-
- onModalClose = () => {
- this.setState({ isDetailsModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- episodeTitle,
- ...otherProps
- } = this.props;
-
- return (
-
-
- {episodeTitle}
-
-
-
-
- );
- }
-}
-
-EpisodeTitleLink.propTypes = {
- episodeTitle: PropTypes.string.isRequired
-};
-
-EpisodeTitleLink.defaultProps = {
- showSeriesButton: false
-};
-
-export default EpisodeTitleLink;
diff --git a/frontend/src/Episode/EpisodeTitleLink.tsx b/frontend/src/Episode/EpisodeTitleLink.tsx
new file mode 100644
index 000000000..f683820e0
--- /dev/null
+++ b/frontend/src/Episode/EpisodeTitleLink.tsx
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { useCallback, useState } from 'react';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
+import FinaleType from './FinaleType';
+import styles from './EpisodeTitleLink.css';
+
+interface EpisodeTitleLinkProps {
+ episodeTitle: string;
+ finaleType?: string;
+}
+
+function EpisodeTitleLink(props: EpisodeTitleLinkProps) {
+ const { episodeTitle, finaleType, ...otherProps } = props;
+ const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
+ const handleLinkPress = useCallback(() => {
+ setIsDetailsModalOpen(true);
+ }, [setIsDetailsModalOpen]);
+ const handleModalClose = useCallback(() => {
+ setIsDetailsModalOpen(false);
+ }, [setIsDetailsModalOpen]);
+
+ return (
+
+
+ {episodeTitle}
+
+
+ {finaleType ? : null}
+
+
+
+ );
+}
+
+EpisodeTitleLink.propTypes = {
+ episodeTitle: PropTypes.string.isRequired,
+ finaleType: PropTypes.string,
+};
+
+export default EpisodeTitleLink;
diff --git a/frontend/src/Episode/FinaleType.css b/frontend/src/Episode/FinaleType.css
new file mode 100644
index 000000000..bdd795fad
--- /dev/null
+++ b/frontend/src/Episode/FinaleType.css
@@ -0,0 +1,5 @@
+.label {
+ composes: label from '~Components/Label.css';
+
+ margin-left: 10px;
+}
diff --git a/frontend/src/Episode/FinaleType.css.d.ts b/frontend/src/Episode/FinaleType.css.d.ts
new file mode 100644
index 000000000..07bf437fb
--- /dev/null
+++ b/frontend/src/Episode/FinaleType.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'label': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Episode/FinaleType.tsx b/frontend/src/Episode/FinaleType.tsx
new file mode 100644
index 000000000..c64377e2a
--- /dev/null
+++ b/frontend/src/Episode/FinaleType.tsx
@@ -0,0 +1,29 @@
+import React, { useMemo } from 'react';
+import Label from 'Components/Label';
+import { kinds } from 'Helpers/Props';
+import getFinaleTypeName from './getFinaleTypeName';
+import styles from './FinaleType.css';
+
+interface SeriesStatusCellProps {
+ finaleType: string;
+}
+
+function FinaleType(props: SeriesStatusCellProps) {
+ const { finaleType } = props;
+
+ const finaleText = useMemo(() => {
+ return getFinaleTypeName(finaleType);
+ }, [finaleType]);
+
+ if (finaleType == null || finaleText == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default FinaleType;
diff --git a/frontend/src/Episode/getFinaleTypeName.ts b/frontend/src/Episode/getFinaleTypeName.ts
new file mode 100644
index 000000000..3bdf4129c
--- /dev/null
+++ b/frontend/src/Episode/getFinaleTypeName.ts
@@ -0,0 +1,14 @@
+import translate from 'Utilities/String/translate';
+
+export default function getFinaleTypeName(finaleType?: string): string | null {
+ switch (finaleType) {
+ case 'series':
+ return translate('SeriesFinale');
+ case 'season':
+ return translate('SeasonFinale');
+ case 'midseason':
+ return translate('MidseasonFinale');
+ default:
+ return null;
+ }
+}
diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js
index 3a692f526..6539b9477 100644
--- a/frontend/src/Series/Details/EpisodeRow.js
+++ b/frontend/src/Series/Details/EpisodeRow.js
@@ -64,6 +64,7 @@ class EpisodeRow extends Component {
sceneAbsoluteEpisodeNumber,
airDateUtc,
runtime,
+ finaleType,
title,
useSceneNumbering,
unverifiedSceneNumbering,
@@ -141,6 +142,7 @@ class EpisodeRow extends Component {
episodeId={id}
seriesId={seriesId}
episodeTitle={title}
+ finaleType={finaleType}
showOpenSeriesButton={false}
/>
@@ -366,6 +368,7 @@ EpisodeRow.propTypes = {
sceneAbsoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string,
runtime: PropTypes.number,
+ finaleType: PropTypes.string,
title: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
useSceneNumbering: PropTypes.bool,
diff --git a/src/NzbDrone.Core/Datastore/Migration/196_add_finale_type.cs b/src/NzbDrone.Core/Datastore/Migration/196_add_finale_type.cs
new file mode 100644
index 000000000..f38fada65
--- /dev/null
+++ b/src/NzbDrone.Core/Datastore/Migration/196_add_finale_type.cs
@@ -0,0 +1,14 @@
+using FluentMigrator;
+using NzbDrone.Core.Datastore.Migration.Framework;
+
+namespace NzbDrone.Core.Datastore.Migration
+{
+ [Migration(196)]
+ public class add_finale_type : NzbDroneMigrationBase
+ {
+ protected override void MainDbUpgrade()
+ {
+ Alter.Table("Episodes").AddColumn("FinaleType").AsString().Nullable();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 8e1a71b9f..e69372cf5 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -693,6 +693,7 @@
"MetadataSource": "Metadata Source",
"MetadataSourceSettings": "Metadata Source Settings",
"MetadataSourceSettingsSummary": "Information on where Sonarr gets series and episode information",
+ "MidseasonFinale": "Midseason Finale",
"Min": "Min",
"MinimumAge": "Minimum Age",
"MinimumAgeHelpText": "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider.",
diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs
index 5fbaf2727..58625bcad 100644
--- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs
+++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs
@@ -15,6 +15,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource
public string AirDate { get; set; }
public DateTime? AirDateUtc { get; set; }
public int Runtime { get; set; }
+ public string FinaleType { get; set; }
public RatingResource Rating { get; set; }
public string Overview { get; set; }
public string Image { get; set; }
diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
index 312069166..3db755f0e 100644
--- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
+++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
@@ -261,6 +261,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
episode.AirDate = oracleEpisode.AirDate;
episode.AirDateUtc = oracleEpisode.AirDateUtc;
episode.Runtime = oracleEpisode.Runtime;
+ episode.FinaleType = oracleEpisode.FinaleType;
episode.Ratings = MapRatings(oracleEpisode.Rating);
diff --git a/src/NzbDrone.Core/Tv/Episode.cs b/src/NzbDrone.Core/Tv/Episode.cs
index a06eb9202..39a95db26 100644
--- a/src/NzbDrone.Core/Tv/Episode.cs
+++ b/src/NzbDrone.Core/Tv/Episode.cs
@@ -37,6 +37,7 @@ namespace NzbDrone.Core.Tv
public List
Images { get; set; }
public DateTime? LastSearchTime { get; set; }
public int Runtime { get; set; }
+ public string FinaleType { get; set; }
public string SeriesTitle { get; private set; }
diff --git a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs
index caa245c5d..2b1355a0d 100644
--- a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs
+++ b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs
@@ -45,7 +45,10 @@ namespace NzbDrone.Core.Tv
dupeFreeRemoteEpisodes = MapAbsoluteEpisodeNumbers(dupeFreeRemoteEpisodes);
}
- foreach (var episode in OrderEpisodes(series, dupeFreeRemoteEpisodes))
+ var orderedEpisodes = OrderEpisodes(series, dupeFreeRemoteEpisodes).ToList();
+ var episodesPerSeason = orderedEpisodes.GroupBy(s => s.SeasonNumber).ToDictionary(g => g.Key, g => g.Count());
+
+ foreach (var episode in orderedEpisodes)
{
try
{
@@ -76,9 +79,16 @@ namespace NzbDrone.Core.Tv
episodeToUpdate.AirDate = episode.AirDate;
episodeToUpdate.AirDateUtc = episode.AirDateUtc;
episodeToUpdate.Runtime = episode.Runtime;
+ episodeToUpdate.FinaleType = episode.FinaleType;
episodeToUpdate.Ratings = episode.Ratings;
episodeToUpdate.Images = episode.Images;
+ // TheTVDB has a severe lack of season/series finales, this helps smooth out that limitation so they can be displayed in the UI
+ if (episodeToUpdate.FinaleType == null && episodeToUpdate.SeasonNumber > 0 && episodeToUpdate.EpisodeNumber > 1 && episodeToUpdate.EpisodeNumber == episodesPerSeason[episodeToUpdate.SeasonNumber])
+ {
+ episodeToUpdate.FinaleType = series.Status == SeriesStatusType.Ended ? "series" : "season";
+ }
+
successCount++;
}
catch (Exception e)
diff --git a/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs b/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs
index fae27e112..d89020b74 100644
--- a/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs
+++ b/src/Sonarr.Api.V3/Episodes/EpisodeResource.cs
@@ -21,6 +21,7 @@ namespace Sonarr.Api.V3.Episodes
public string AirDate { get; set; }
public DateTime? AirDateUtc { get; set; }
public int Runtime { get; set; }
+ public string FinaleType { get; set; }
public string Overview { get; set; }
public EpisodeFileResource EpisodeFile { get; set; }
public bool HasFile { get; set; }
@@ -64,6 +65,7 @@ namespace Sonarr.Api.V3.Episodes
AirDate = model.AirDate,
AirDateUtc = model.AirDateUtc,
Runtime = model.Runtime,
+ FinaleType = model.FinaleType,
Overview = model.Overview,
// EpisodeFile