New: Show midseason and other finales in episode list

Closes #5719
This commit is contained in:
Mark McDowall 2023-08-19 01:36:18 -07:00
parent aceaaa10e1
commit 311cd66fcd
19 changed files with 157 additions and 87 deletions

View File

@ -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 (
<div className={styles.event}>
@ -189,15 +189,14 @@ class AgendaEvent extends Component {
{
showFinaleIcon &&
episodeNumber !== 1 &&
seasonNumber > 0 &&
episodeNumber === seasonStatistics.totalEpisodeCount &&
finaleType ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.WARNING}
title={series.status === 'ended' ? translate('SeriesFinale') : translate('SeasonFinale')}
/>
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,

View File

@ -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 (
<div
@ -170,14 +170,12 @@ class CalendarEvent extends Component {
{
showFinaleIcon &&
episodeNumber !== 1 &&
seasonNumber > 0 &&
episodeNumber === seasonStatistics.totalEpisodeCount ?
finaleType ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
title={series.status === 'ended' ? translate('SeriesFinale') : translate('SeasonFinale')}
title={getFinaleTypeName(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,

View File

@ -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 ?
<Icon
containerClassName={styles.statusIcon}
name={icons.INFO}
kind={fullColorEvents ? kinds.DEFAULT : kinds.WARNING}
title={series.status === 'ended' ? translate('SeriesFinale') : translate('SeasonFinale')}
/>
title={getFinaleTypeName(lastEpisode.finaleType)}
/> : null
}
</div>

View File

@ -1,3 +1,8 @@
.container {
display: flex;
align-items: center;
}
.link {
composes: link from '~Components/Link/Link.css';

View File

@ -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;

View File

@ -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 (
<div>
<Link
className={styles.link}
onPress={this.onLinkPress}
>
{episodeTitle}
</Link>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeTitle={episodeTitle}
{...otherProps}
onModalClose={this.onModalClose}
/>
</div>
);
}
}
EpisodeTitleLink.propTypes = {
episodeTitle: PropTypes.string.isRequired
};
EpisodeTitleLink.defaultProps = {
showSeriesButton: false
};
export default EpisodeTitleLink;

View File

@ -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 (
<div className={styles.container}>
<Link className={styles.link} onPress={handleLinkPress}>
{episodeTitle}
</Link>
{finaleType ? <FinaleType finaleType={finaleType} /> : null}
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeTitle={episodeTitle}
{...otherProps}
onModalClose={handleModalClose}
/>
</div>
);
}
EpisodeTitleLink.propTypes = {
episodeTitle: PropTypes.string.isRequired,
finaleType: PropTypes.string,
};
export default EpisodeTitleLink;

View File

@ -0,0 +1,5 @@
.label {
composes: label from '~Components/Label.css';
margin-left: 10px;
}

View File

@ -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;

View File

@ -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 (
<Label className={styles.label} kind={kinds.INFO}>
{finaleText}
</Label>
);
}
export default FinaleType;

View File

@ -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;
}
}

View File

@ -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}
/>
</TableRowCell>
@ -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,

View File

@ -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();
}
}
}

View File

@ -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.",

View File

@ -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; }

View File

@ -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);

View File

@ -37,6 +37,7 @@ namespace NzbDrone.Core.Tv
public List<MediaCover.MediaCover> Images { get; set; }
public DateTime? LastSearchTime { get; set; }
public int Runtime { get; set; }
public string FinaleType { get; set; }
public string SeriesTitle { get; private set; }

View File

@ -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)

View File

@ -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