From 311cd66fcded627e8780743c93feecc1466d477b Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 19 Aug 2023 01:36:18 -0700 Subject: [PATCH] New: Show midseason and other finales in episode list Closes #5719 --- frontend/src/Calendar/Agenda/AgendaEvent.js | 14 ++-- frontend/src/Calendar/Events/CalendarEvent.js | 11 ++- .../src/Calendar/Events/CalendarEventGroup.js | 9 ++- frontend/src/Episode/EpisodeTitleLink.css | 5 ++ .../src/Episode/EpisodeTitleLink.css.d.ts | 1 + frontend/src/Episode/EpisodeTitleLink.js | 68 ------------------- frontend/src/Episode/EpisodeTitleLink.tsx | 46 +++++++++++++ frontend/src/Episode/FinaleType.css | 5 ++ frontend/src/Episode/FinaleType.css.d.ts | 7 ++ frontend/src/Episode/FinaleType.tsx | 29 ++++++++ frontend/src/Episode/getFinaleTypeName.ts | 14 ++++ frontend/src/Series/Details/EpisodeRow.js | 3 + .../Migration/196_add_finale_type.cs | 14 ++++ src/NzbDrone.Core/Localization/Core/en.json | 1 + .../SkyHook/Resource/EpisodeResource.cs | 1 + .../MetadataSource/SkyHook/SkyHookProxy.cs | 1 + src/NzbDrone.Core/Tv/Episode.cs | 1 + src/NzbDrone.Core/Tv/RefreshEpisodeService.cs | 12 +++- src/Sonarr.Api.V3/Episodes/EpisodeResource.cs | 2 + 19 files changed, 157 insertions(+), 87 deletions(-) delete mode 100644 frontend/src/Episode/EpisodeTitleLink.js create mode 100644 frontend/src/Episode/EpisodeTitleLink.tsx create mode 100644 frontend/src/Episode/FinaleType.css create mode 100644 frontend/src/Episode/FinaleType.css.d.ts create mode 100644 frontend/src/Episode/FinaleType.tsx create mode 100644 frontend/src/Episode/getFinaleTypeName.ts create mode 100644 src/NzbDrone.Core/Datastore/Migration/196_add_finale_type.cs 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