Crunchy-Downloader/CRD/ViewModels/UpcomingSeasonsPageViewModel.cs
Elwador dc570bf420 - Added **toggle to also download description audio** for selected dubs
- Added **automatic history backups** retained for up to 5 days
- Improved **season tab** to display series more effectively
- Improved **history saving** for increased data safety
- Removed **"None" option** from the hardsub selection popup
2025-11-06 20:44:03 +01:00

605 lines
No EOL
23 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Views;
using FluentAvalonia.UI.Data;
using Newtonsoft.Json;
using ReactiveUI;
namespace CRD.ViewModels;
public partial class UpcomingPageViewModel : ViewModelBase{
#region Query
private string query = @"query (
$season: MediaSeason,
$year: Int,
$format: MediaFormat,
$excludeFormat: MediaFormat,
$status: MediaStatus,
$minEpisodes: Int,
$page: Int,
){
Page(page: $page) {
pageInfo {
hasNextPage
total
}
media(
season: $season
seasonYear: $year
format: $format,
format_not: $excludeFormat,
status: $status,
episodes_greater: $minEpisodes,
isAdult: false,
type: ANIME,
sort: TITLE_ENGLISH,
) {
id
idMal
title {
romaji
native
english
}
startDate {
year
month
day
}
endDate {
year
month
day
}
status
season
format
genres
synonyms
duration
popularity
episodes
source(version: 2)
countryOfOrigin
hashtag
averageScore
siteUrl
description
bannerImage
isAdult
coverImage {
extraLarge
color
}
trailer {
id
site
thumbnail
}
externalLinks {
site
icon
color
url
}
rankings {
rank
type
season
allTime
}
studios(isMain: true) {
nodes {
id
name
siteUrl
}
}
relations {
edges {
relationType(version: 2)
node {
id
title {
romaji
native
english
}
siteUrl
}
}
}
airingSchedule(
notYetAired: true
perPage: 2
) {
nodes {
episode
airingAt
}
}
}
}
}";
#endregion
[ObservableProperty]
private AnilistSeries? _selectedSeries;
[ObservableProperty]
private int _selectedIndex;
[ObservableProperty]
private bool _quickAddMode;
[ObservableProperty]
private static bool _showCrFetches;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private SortingListElement? _selectedSorting;
[ObservableProperty]
private static bool _sortingSelectionOpen;
private SortingType currentSortingType;
[ObservableProperty]
private static bool _sortDir;
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
public ObservableCollection<SeasonViewModel> Seasons{ get; set; } =[];
public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } =[];
private SeasonViewModel currentSelection;
public UpcomingPageViewModel(){
LoadSeasons();
}
private async void LoadSeasons(){
SeasonsPageProperties? properties = CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties;
currentSortingType = properties?.SelectedSorting ?? SortingType.SeriesTitle;
SortDir = properties?.Ascending ?? false;
foreach (SortingType sortingType in Enum.GetValues(typeof(SortingType))){
if (sortingType == SortingType.HistorySeriesAddDate){
continue;
}
var combobox = new SortingListElement(){ SortingTitle = sortingType.GetEnumMemberValue(), SelectedSorting = sortingType };
SortingList.Add(combobox);
if (sortingType == currentSortingType){
SelectedSorting = combobox;
}
}
Seasons = GetTargetSeasonsAndYears();
currentSelection = Seasons.Last();
currentSelection.IsSelected = true;
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false, crunchySimul);
SelectedSeason.Clear();
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
}
}
}
FilterItems();
SortItems();
}
[RelayCommand]
public async Task SelectSeasonCommand(SeasonViewModel selectedSeason){
currentSelection.IsSelected = false;
currentSelection = selectedSeason;
currentSelection.IsSelected = true;
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false, crunchySimul);
SelectedSeason.Clear();
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
}
}
}
FilterItems();
SortItems();
}
[RelayCommand]
public void OpenTrailer(AnilistSeries series){
if (series.Trailer != null){
if (series.Trailer.Site.Equals("youtube")){
var url = "https://www.youtube.com/watch?v=" + series.Trailer.Id;
Process.Start(new ProcessStartInfo{
FileName = url,
UseShellExecute = true
});
}
}
}
[RelayCommand]
public async Task AddToHistory(AnilistSeries series){
if (ProgramManager.Instance.FetchingData){
MessageBus.Current.SendMessage(new ToastMessage($"History still loading", ToastType.Warning, 3));
return;
}
if (!string.IsNullOrEmpty(series.CrunchyrollID)){
if (CrunchyrollManager.Instance.CrunOptions.History){
series.IsInHistory = true;
RaisePropertyChanged(nameof(series.IsInHistory));
var sucess = await CrunchyrollManager.Instance.History.CrUpdateSeries(series.CrunchyrollID, "");
series.IsInHistory = sucess;
RaisePropertyChanged(nameof(series.IsInHistory));
if (sucess){
MessageBus.Current.SendMessage(new ToastMessage($"Series added to History", ToastType.Information, 3));
} else{
MessageBus.Current.SendMessage(new ToastMessage($"Series couldn't get added to History\n(maybe not available in your region)", ToastType.Error, 3));
}
} else{
MessageBus.Current.SendMessage(new ToastMessage($"Series couldn't get added to History", ToastType.Error, 3));
}
} else{
MessageBus.Current.SendMessage(new ToastMessage($"Series couldn't get added to History", ToastType.Error, 3));
}
}
private async Task<List<AnilistSeries>> GetSeriesForSeason(string season, int year, bool forceRefresh, CrBrowseSeriesBase? crBrowseSeriesBase){
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(season + year) && !forceRefresh){
return ProgramManager.Instance.AnilistSeasons[season + year];
}
IsLoading = true;
var allMedia = new List<AnilistSeries>();
var page = 1;
var maxPage = 10;
bool hasNext;
do{
var payload = new{
query,
variables = new{ season, year, page }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Anilist);
request.Content = new StringContent(JsonConvert.SerializeObject(payload, Formatting.Indented),
Encoding.UTF8, "application/json");
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine($"Anilist Request Failed for {season} {year} (page {page})");
break;
}
var ani = Helpers.Deserialize<AniListResponse>(
response.ResponseContent,
CrunchyrollManager.Instance.SettingsJsonSerializerSettings
) ?? new AniListResponse();
var pageNode = ani.Data?.Page;
var media = pageNode?.Media ?? new List<AnilistSeries>();
allMedia.AddRange(media);
hasNext = pageNode?.PageInfo?.HasNextPage ?? false;
page++;
} while (hasNext || page <= maxPage);
var list = allMedia.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external =>
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList();
foreach (var anilistEle in list){
anilistEle.ThumbnailImage = await Helpers.LoadImage(anilistEle.CoverImage.ExtraLarge, 185, 265);
anilistEle.Description = anilistEle.Description
.Replace("<i>", "")
.Replace("</i>", "")
.Replace("<b>", "")
.Replace("</b>", "")
.Replace("<BR>", "")
.Replace("<br>", "");
if (anilistEle.ExternalLinks != null){
var url = anilistEle.ExternalLinks.First(external =>
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase)).Url;
string pattern = @"series\/([^\/]+)";
Match match = Regex.Match(url, pattern);
if (match.Success){
anilistEle.CrunchyrollID = match.Groups[1].Value;
anilistEle.HasCrID = true;
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyIDs = new HashSet<string>(CrunchyrollManager.Instance.HistoryList.Select(item => item.SeriesId ?? ""));
if (historyIDs.Contains(anilistEle.CrunchyrollID)){
anilistEle.IsInHistory = true;
}
}
} else{
Uri uri = new Uri(url);
if (uri.Host == "www.crunchyroll.com"
&& uri.AbsolutePath != "/"
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)){
HttpRequestMessage getUrlRequest = new HttpRequestMessage(HttpMethod.Head, url);
string? finalUrl = "";
try{
HttpResponseMessage getUrlResponse = await HttpClientReq.Instance.GetHttpClient().SendAsync(getUrlRequest);
finalUrl = getUrlResponse.RequestMessage?.RequestUri?.ToString();
} catch (Exception ex){
Console.WriteLine($"Error: {ex.Message}");
}
Match match2 = Regex.Match(finalUrl ?? string.Empty, pattern);
if (match2.Success){
anilistEle.CrunchyrollID = match2.Groups[1].Value;
anilistEle.HasCrID = true;
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyIDs = new HashSet<string>(CrunchyrollManager.Instance.HistoryList.Select(item => item.SeriesId ?? ""));
if (historyIDs.Contains(anilistEle.CrunchyrollID)){
anilistEle.IsInHistory = true;
}
}
} else{
anilistEle.CrunchyrollID = "";
anilistEle.HasCrID = false;
}
} else{
anilistEle.CrunchyrollID = "";
anilistEle.HasCrID = false;
}
}
}
}
var existingIds = list
.Where(a => !string.IsNullOrEmpty(a.CrunchyrollID))
.Select(a => a.CrunchyrollID!)
.ToHashSet(StringComparer.Ordinal);
var notInList = (crBrowseSeriesBase?.Data ?? Enumerable.Empty<CrBrowseSeries>())
.ExceptBy(existingIds, cs => cs.Id, StringComparer.Ordinal)
.ToList();
foreach (var crBrowseSeries in notInList){
var newAnlistObject = new AnilistSeries();
newAnlistObject.Title = new Title();
newAnlistObject.Title.English = crBrowseSeries.Title ?? "";
newAnlistObject.Description = crBrowseSeries.Description ?? "";
newAnlistObject.CoverImage = new CoverImage();
int targetW = 240, targetH = 360;
string posterSrc =
crBrowseSeries.Images.PosterTall.FirstOrDefault()?
.OrderBy(i => Math.Abs(i.Width - targetW) + Math.Abs(i.Height - targetH))
.ThenBy(i => Math.Abs((i.Width / (double)i.Height) - (targetW / (double)targetH)))
.Select(i => i.Source)
.FirstOrDefault(s => !string.IsNullOrEmpty(s))
?? crBrowseSeries.Images.PosterTall.FirstOrDefault()?.FirstOrDefault()?.Source
?? string.Empty;
newAnlistObject.CoverImage.ExtraLarge = posterSrc;
newAnlistObject.ThumbnailImage = await Helpers.LoadImage(newAnlistObject.CoverImage.ExtraLarge, 185, 265);
newAnlistObject.ExternalLinks = new List<ExternalLink>();
newAnlistObject.ExternalLinks.Add(new ExternalLink(){ Url = $"https://www.crunchyroll.com/series/{crBrowseSeries.Id}/{crBrowseSeries.SlugTitle}" });
newAnlistObject.FetchedFromCR = true;
newAnlistObject.HasCrID = true;
newAnlistObject.CrunchyrollID = crBrowseSeries.Id;
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyIDs = new HashSet<string>(CrunchyrollManager.Instance.HistoryList.Select(item => item.SeriesId ?? ""));
if (newAnlistObject.CrunchyrollID != null && historyIDs.Contains(newAnlistObject.CrunchyrollID)){
newAnlistObject.IsInHistory = true;
}
}
list.Add(newAnlistObject);
}
ProgramManager.Instance.AnilistSeasons[season + year] = list;
IsLoading = false;
return list;
}
private ObservableCollection<SeasonViewModel> GetTargetSeasonsAndYears(){
DateTime now = DateTime.Now;
int currentMonth = now.Month;
int currentYear = now.Year;
string currentSeason;
if (currentMonth >= 1 && currentMonth <= 3)
currentSeason = "WINTER";
else if (currentMonth >= 4 && currentMonth <= 6)
currentSeason = "SPRING";
else if (currentMonth >= 7 && currentMonth <= 9)
currentSeason = "SUMMER";
else
currentSeason = "FALL";
var seasons = new List<string>{ "WINTER", "SPRING", "SUMMER", "FALL" };
int currentSeasonIndex = seasons.IndexOf(currentSeason);
var targetSeasons = new ObservableCollection<SeasonViewModel>();
// Includes: -2 (two seasons ago), -1 (previous), 0 (current), 1 (next)
for (int i = -2; i <= 1; i++){
int targetIndex = (currentSeasonIndex + i + 4) % 4;
string targetSeason = seasons[targetIndex];
int targetYear = currentYear;
if (i < 0 && targetIndex == 3){
targetYear--;
} else if (i > 0 && targetIndex == 0){
targetYear++;
}
targetSeasons.Add(new SeasonViewModel(){ Season = targetSeason, Year = targetYear });
}
return targetSeasons;
}
public void SelectionChangedOfSeries(AnilistSeries? value){
if (value != null && !QuickAddMode) value.IsExpanded = !value.IsExpanded;
SelectedSeries = null;
SelectedIndex = -1;
}
partial void OnSelectedSeriesChanged(AnilistSeries? value){
SelectionChangedOfSeries(value);
}
partial void OnShowCrFetchesChanged(bool value){
FilterItems();
SortItems();
}
#region Sorting
private void UpdateSettings(){
if (CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties != null){
CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.SelectedSorting = currentSortingType;
CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.Ascending = SortDir;
} else{
CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties = new SeasonsPageProperties(){ SelectedSorting = currentSortingType, Ascending = SortDir };
}
CfgManager.WriteCrSettings();
}
partial void OnSelectedSortingChanged(SortingListElement? oldValue, SortingListElement? newValue){
if (newValue == null){
if (CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties != null){
CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.Ascending = !CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.Ascending;
SortDir = CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.Ascending;
}
Dispatcher.UIThread.InvokeAsync(() => {
SelectedSorting = oldValue ?? SortingList.First();
RaisePropertyChanged(nameof(SelectedSorting));
});
return;
}
currentSortingType = newValue.SelectedSorting;
if (CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties != null) CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.SelectedSorting = currentSortingType;
SortItems();
SortingSelectionOpen = false;
UpdateSettings();
}
private void SortItems(){
var sortingDir = CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties != null && CrunchyrollManager.Instance.CrunOptions.SeasonsPageProperties.Ascending;
var sortedList = currentSortingType switch{
SortingType.SeriesTitle => sortingDir
? SelectedSeason
.OrderByDescending(item => item.Title.English)
.ToList()
: SelectedSeason
.OrderBy(item => item.Title.English)
.ToList(),
SortingType.NextAirDate => sortingDir
? SelectedSeason
.OrderByDescending(item => item.StartDate?.ToDateTime() ?? DateTime.MinValue)
.ThenByDescending(item => item.Title.English)
.ToList()
: SelectedSeason
.OrderBy(item => item.StartDate?.ToDateTime() ?? DateTime.MinValue)
.ThenBy(item => item.Title.English)
.ToList(),
_ => SelectedSeason.ToList()
};
SelectedSeason.Clear();
foreach (var item in sortedList){
SelectedSeason.Add(item);
}
}
private void FilterItems(){
List<AnilistSeries> filteredList;
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){
filteredList = ProgramManager.Instance.AnilistSeasons[currentSelection.Season + currentSelection.Year];
} else{
return;
}
filteredList = !ShowCrFetches ? filteredList.Where(e => !e.FetchedFromCR).ToList() : filteredList.ToList();
SelectedSeason.Clear();
foreach (var item in filteredList){
SelectedSeason.Add(item);
}
}
#endregion
}