Compare commits

...

6 commits

Author SHA1 Message Date
Elwador
ff3e28093e - Added notification service for webhooks
- Added retry delay for rate limit handling
- Added toggle to control whether auto refresh also adds missing episodes to the queue
- Added configurable delay after each dub download
- Changed encoding preset dialog to show a preview of the FFmpeg command
- Changed play sound on queue empty and execute file on completion to be handled by the notification service
- Changed shutdown PC option to disable once triggered
- Fixed crash with queue persistence
- Fixed crash with audio player
- Fixed subscription countdown on the account page
2026-05-14 21:49:57 +02:00
Elwador
d9813191ad - Added **Global Pause button** for the download queue [#418](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/418)
- Added **fallback for sync failures** [#407](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/407)
- Added **history setting** to remove non-existent series/episodes on refresh [#420](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/420)
- Added **movies to history**
- Added **queue persistence**
- Changed **download item state handling**
- Changed **download item removal processing**
- Made small changes to **font detection**
- Changed **rate limit error handling**
- Fixed issue where **files were not always cleaned up** for removed downloads
- Fixed **crash when the queue list was modified**
- Fixed **changelog parsing** not handling versions like `vX.X.X.X`, which caused changes to be re-added on every restart
2026-04-20 15:40:58 +02:00
Elwador
638c412e49 Chg: Docker tags 2026-04-18 13:07:37 +02:00
Elwador
9be2c65eb2 Chg: Add missing image 2026-04-18 12:18:24 +02:00
Elwador
2715069ceb Fix: Workflow 2026-04-18 12:09:26 +02:00
Elwador
4952d74aa6 Add: webtop Docker support 2026-04-17 22:29:56 +02:00
65 changed files with 4423 additions and 1287 deletions

14
.dockerignore Normal file
View file

@ -0,0 +1,14 @@
.git
.idea
.vs
_builds
CRD/*
!CRD/Assets/
!CRD/Assets/**
docker/*
!docker/crd.desktop
!docker/50-crd-shortcuts
!docker/crd-linux-x64/
!docker/crd-linux-x64/**

View file

@ -0,0 +1,123 @@
name: Docker From Release
on:
release:
types:
- published
workflow_dispatch:
inputs:
tag:
description: Release tag to build from
required: true
type: string
asset_contains:
description: Substring used to find the Linux zip asset
required: false
default: linux-x64
type: string
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/crunchy-downloader-webtop
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve release asset
id: release
uses: actions/github-script@v7
env:
INPUT_TAG: ${{ inputs.tag || '' }}
INPUT_ASSET_CONTAINS: ${{ inputs.asset_contains || 'linux-x64' }}
with:
script: |
const tag = context.eventName === "release"
? context.payload.release.tag_name
: process.env.INPUT_TAG;
const assetContains = context.eventName === "release"
? "linux-x64"
: process.env.INPUT_ASSET_CONTAINS;
const { data: release } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
const asset = release.assets.find(a =>
a.name.toLowerCase().includes(assetContains.toLowerCase()) &&
a.name.toLowerCase().endsWith(".zip")
);
if (!asset) {
core.setFailed(`No release asset containing "${assetContains}" and ending in .zip was found on ${tag}.`);
return;
}
core.setOutput("tag", tag);
core.setOutput("asset_name", asset.name);
core.setOutput("asset_api_url", asset.url);
core.setOutput("is_prerelease", String(release.prerelease));
- name: Download release zip
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api -H "Accept: application/octet-stream" "${{ steps.release.outputs.asset_api_url }}" > release.zip
- name: Stage Docker payload
run: |
rm -rf docker/crd-linux-x64
mkdir -p docker/crd-linux-x64 docker/.tmp-release
unzip -q release.zip -d docker/.tmp-release
shopt -s dotglob nullglob
entries=(docker/.tmp-release/*)
if [ ${#entries[@]} -eq 1 ] && [ -d "${entries[0]}" ]; then
cp -a "${entries[0]}/." docker/crd-linux-x64/
else
cp -a docker/.tmp-release/. docker/crd-linux-x64/
fi
test -f docker/crd-linux-x64/CRD
rm -rf docker/.tmp-release
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=semver,pattern={{version}},value=${{ steps.release.outputs.tag }}
type=raw,value=current
type=raw,value=latest,enable=${{ steps.release.outputs.is_prerelease != 'true' }}
type=raw,value=stable,enable=${{ steps.release.outputs.is_prerelease != 'true' }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.webtop
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -29,6 +29,7 @@ public class App : Application{
var isHeadless = Environment.GetCommandLineArgs().Contains("--headless"); var isHeadless = Environment.GetCommandLineArgs().Contains("--headless");
var manager = ProgramManager.Instance; var manager = ProgramManager.Instance;
QueueManager.Instance.RestorePersistedQueue();
if (!isHeadless){ if (!isHeadless){
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
@ -38,7 +39,10 @@ public class App : Application{
}; };
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); }; mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); }; desktop.Exit += (_, _) => {
QueueManager.Instance.SaveQueueSnapshot();
manager.StopBackgroundTasks();
};
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); }; QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){ if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
@ -148,7 +152,7 @@ public class App : Application{
} }
public void UpdateTrayTooltip(){ public void UpdateTrayTooltip(){
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => e.DownloadProgress is{ Done: false, Error: false }); var downloadsToProcess = QueueManager.Instance.Queue.Count(e => !e.DownloadProgress.IsFinished);
var options = CrunchyrollManager.Instance.CrunOptions; var options = CrunchyrollManager.Instance.CrunOptions;
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime(); var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();

BIN
CRD/Assets/app_icon.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -82,7 +82,7 @@ public class CalendarManager{
request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"); request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br"); request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
(bool IsOk, string ResponseContent, string error) response; (bool IsOk, string ResponseContent, string error, Dictionary<string,string> Headers) response;
if (!HttpClientReq.Instance.UseFlareSolverr){ if (!HttpClientReq.Instance.UseFlareSolverr){
response = await HttpClientReq.Instance.SendHttpRequest(request); response = await HttpClientReq.Instance.SendHttpRequest(request);
} else{ } else{

View file

@ -13,6 +13,7 @@ using System.Web;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http; using CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Views; using CRD.Views;
@ -22,6 +23,8 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll; namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60);
public CrToken? Token; public CrToken? Token;
public CrProfile Profile = new(); public CrProfile Profile = new();
public Subscription? Subscription{ get; set; } public Subscription? Subscription{ get; set; }
@ -33,8 +36,11 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
public Dictionary<string, CookieCollection> cookieStore = new(); public Dictionary<string, CookieCollection> cookieStore = new();
public void Init(){ private bool IsTokenExpiredOrNearExpiry(){
return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer;
}
public void Init(){
Profile = new CrProfile{ Profile = new CrProfile{
Username = "???", Username = "???",
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png", Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
@ -406,7 +412,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
public async Task RefreshToken(bool needsToken){ public async Task RefreshToken(bool needsToken){
if (EndpointEnum == CrunchyrollEndpoints.Guest){ if (EndpointEnum == CrunchyrollEndpoints.Guest){
if (Token != null && !(DateTime.Now > Token.expires)){ if (!IsTokenExpiredOrNearExpiry()){
return; return;
} }
@ -418,7 +424,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
Token.access_token != null && Token.refresh_token == null){ Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous(); await AuthAnonymous();
} else{ } else{
if (!(DateTime.Now > Token.expires) && needsToken){ if (!IsTokenExpiredOrNearExpiry() && needsToken){
return; return;
} }
} }
@ -427,6 +433,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
return; return;
} }
var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != "???";
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id; string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
var formData = new Dictionary<string, string>{ var formData = new Dictionary<string, string>{
@ -464,6 +472,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
JsonTokenToFileAndVariable(response.ResponseContent, uuid); JsonTokenToFileAndVariable(response.ResponseContent, uuid);
} else{ } else{
Console.Error.WriteLine("Refresh Token Auth Failed"); Console.Error.WriteLine("Refresh Token Auth Failed");
if (hadUserSession){
await NotificationPublisher.Instance.PublishLoginExpiredAsync(crunInstance.CrunOptions.NotificationSettings, Profile.Username, AuthSettings.Endpoint);
}
} }
} }
} }

View file

@ -78,9 +78,7 @@ public class CrMovies{
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source; epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source;
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source; epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source;
epMeta.DownloadProgress = new DownloadProgress(){ epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false, State = DownloadState.Queued,
Done = false,
Error = false,
Percent = 0, Percent = 0,
Time = 0, Time = 0,
DownloadSpeedBytes = 0 DownloadSpeedBytes = 0

View file

@ -184,9 +184,7 @@ public class CrMusic{
epMeta.Image = images.FirstOrDefault()?.Source ?? string.Empty; epMeta.Image = images.FirstOrDefault()?.Source ?? string.Empty;
epMeta.ImageBig = images.FirstOrDefault()?.Source ?? string.Empty; epMeta.ImageBig = images.FirstOrDefault()?.Source ?? string.Empty;
epMeta.DownloadProgress = new DownloadProgress(){ epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false, State = DownloadState.Queued,
Done = false,
Error = false,
Percent = 0, Percent = 0,
Time = 0, Time = 0,
DownloadSpeedBytes = 0 DownloadSpeedBytes = 0

View file

@ -0,0 +1,439 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Views;
using ReactiveUI;
namespace CRD.Downloader.Crunchyroll;
public class CrQueue{
public async Task CrAddEpisodeToQueue(string epId, string crLocale, List<string> dubLang, bool updateHistory = false, EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default){
if (string.IsNullOrEmpty(epId)){
return;
}
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var episodeL = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(epId, crLocale);
if (episodeL != null){
if (episodeL.IsPremiumOnly && !CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.HasPremium){
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
return;
}
var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episodeL, updateHistory);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist;
}
}
var selected = CrunchyrollManager.Instance.CrEpisode.EpisodeMeta(sList, dubLang);
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
if (selected.Data is{ Count: > 0 }){
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(selected.Data.First().MediaId,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
selected.Description = episode?.Description ?? selected.Description;
}
}
if (selected.Data is{ Count: > 0 }){
if (CrunchyrollManager.Instance.CrunOptions.History){
// var historyEpisode = CrHistory.GetHistoryEpisodeWithDownloadDir(selected.ShowId, selected.SeasonId, selected.Data.First().MediaId);
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
if (historyEpisode.historyEpisode != null){
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
selected.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
}
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
selected.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
}
}
}
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
selected.DownloadPath = historyEpisode.downloadDirPath;
}
}
selected.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
selected.DownloadSubs = historyEpisode.sublist.Count > 0 ? historyEpisode.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
selected.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && selected.Data.Count > 1){
var sortedMetaData = selected.Data
.OrderBy(metaData => {
var locale = metaData.Lang?.CrLocale ?? string.Empty;
var index = dubLang.IndexOf(locale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
if (sortedMetaData.Count != 0){
var first = sortedMetaData.First();
selected.Data = [first];
selected.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
}
}
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false;
newOptions.Noaudio = true;
selected.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true;
newOptions.Noaudio = false;
selected.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true;
newOptions.Noaudio = true;
break;
case EpisodeDownloadMode.Default:
default:
break;
}
if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
selected.HighlightAllAvailable = true;
}
}
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!selected.DownloadSubs.Contains("none") && !selected.DownloadSubs.Contains("all") && !selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
return;
}
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
return;
}
}
newOptions.DubLang = dubLang;
selected.DownloadSettings = newOptions;
QueueManager.Instance.AddToQueue(selected);
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
.ToArray();
Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
} else{
Console.WriteLine("Added Episode to Queue");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
}
} else{
Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
.ToArray();
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
if (!CrunchyrollManager.Instance.CrunOptions.DownloadOnlyWithAllSelectedDubSub){
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
}
}
return;
}
Console.WriteLine("Couldn't find episode trying to find movie with id");
var movie = await CrunchyrollManager.Instance.CrMovies.ParseMovieById(epId, crLocale);
if (movie != null){
var movieMeta = CrunchyrollManager.Instance.CrMovies.EpisodeMeta(movie, dubLang);
if (movieMeta != null){
movieMeta.DownloadSubs = CrunchyrollManager.Instance.CrunOptions.DlSubs;
movieMeta.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false;
newOptions.Noaudio = true;
movieMeta.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true;
newOptions.Noaudio = false;
movieMeta.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true;
newOptions.Noaudio = true;
break;
case EpisodeDownloadMode.Default:
default:
break;
}
newOptions.DubLang = dubLang;
movieMeta.DownloadSettings = newOptions;
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!movieMeta.DownloadSubs.Contains("none") && !movieMeta.DownloadSubs.Contains("all") && !movieMeta.DownloadSubs.All(item => (movieMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
if (movieMeta.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
}
QueueManager.Instance.AddToQueue(movieMeta);
Console.WriteLine("Added Movie to Queue");
MessageBus.Current.SendMessage(new ToastMessage($"Added Movie to Queue", ToastType.Information, 1));
return;
}
}
Console.Error.WriteLine($"No episode or movie found with the id: {epId}");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue - No episode or movie found with the id: {epId}", ToastType.Error, 3));
}
public void CrAddMusicMetaToQueue(CrunchyEpMeta epMeta){
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
epMeta.DownloadSettings = newOptions;
QueueManager.Instance.AddToQueue(epMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
}
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
if (musicVideo != null){
var musicVideoMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(musicVideo);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
}
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
musicVideoMeta.DownloadSettings = newOptions;
QueueManager.Instance.AddToQueue(musicVideoMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added music video to the queue", ToastType.Information, 1));
}
}
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
if (concert != null){
var concertMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(concert);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
}
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
concertMeta.DownloadSettings = newOptions;
QueueManager.Instance.AddToQueue(concertMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added concert to the queue", ToastType.Information, 1));
}
}
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var failed = false;
var partialAdd = false;
foreach (var crunchyEpMeta in selected.Values.ToList()){
if (crunchyEpMeta.Data.FirstOrDefault() != null){
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDownloadDir(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId, crunchyEpMeta.Data.First().MediaId);
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
if (historyEpisode.historyEpisode != null){
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
crunchyEpMeta.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
}
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
crunchyEpMeta.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
}
}
}
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
crunchyEpMeta.DownloadPath = historyEpisode.downloadDirPath;
}
}
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
if (crunchyEpMeta.Data is{ Count: > 0 }){
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(crunchyEpMeta.Data.First().MediaId,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
crunchyEpMeta.Description = episode?.Description ?? crunchyEpMeta.Description;
}
}
var subLangList = CrunchyrollManager.Instance.History.GetSubList(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId);
crunchyEpMeta.VideoQuality = !string.IsNullOrEmpty(subLangList.videoQuality) ? subLangList.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
crunchyEpMeta.DownloadSubs = subLangList.sublist.Count > 0 ? subLangList.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && crunchyEpMeta.Data.Count > 1){
var sortedMetaData = crunchyEpMeta.Data
.OrderBy(metaData => {
var locale = metaData.Lang?.CrLocale ?? string.Empty;
var index = data.DubLang.IndexOf(locale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
if (sortedMetaData.Count != 0){
var first = sortedMetaData.First();
crunchyEpMeta.Data = [first];
crunchyEpMeta.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
}
}
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
if (crunchyEpMeta.OnlySubs){
newOptions.Novids = true;
newOptions.Noaudio = true;
}
newOptions.DubLang = data.DubLang;
crunchyEpMeta.DownloadSettings = newOptions;
if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
crunchyEpMeta.HighlightAllAvailable = true;
}
}
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!crunchyEpMeta.DownloadSubs.Contains("none") && !crunchyEpMeta.DownloadSubs.Contains("all") && !crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
}
QueueManager.Instance.AddToQueue(crunchyEpMeta);
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
partialAdd = true;
var languages = (crunchyEpMeta.Data.First().Versions ?? []).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray();
Console.Error.WriteLine(
$"{crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", crunchyEpMeta.AvailableSubs ?? [])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
}
} else{
failed = true;
}
}
if (failed && !partialAdd){
MainWindow.Instance.ShowError("Not all episodes could be added make sure that you are signed in with an account that has an active premium subscription?");
} else if (selected.Values.Count > 0 && !partialAdd){
MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1));
} else if (!partialAdd){
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2));
}
}
}

View file

@ -22,12 +22,14 @@ using CRD.Utils.Muxing;
using CRD.Utils.Muxing.Fonts; using CRD.Utils.Muxing.Fonts;
using CRD.Utils.Muxing.Structs; using CRD.Utils.Muxing.Structs;
using CRD.Utils.Muxing.Syncing; using CRD.Utils.Muxing.Syncing;
using CRD.Utils.Notifications;
using CRD.Utils.Parser; using CRD.Utils.Parser;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.Utils.Sonarr.Models; using CRD.Utils.Sonarr.Models;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using CRD.ViewModels; using CRD.ViewModels;
using CRD.ViewModels.Utils; using CRD.ViewModels.Utils;
using CRD.Views; using CRD.Views;
@ -76,6 +78,8 @@ public class CrunchyrollManager{
public CrSeries CrSeries; public CrSeries CrSeries;
public CrMovies CrMovies; public CrMovies CrMovies;
public CrMusic CrMusic; public CrMusic CrMusic;
public CrQueue CrQueue;
public History History; public History History;
#region Singelton #region Singelton
@ -110,11 +114,13 @@ public class CrunchyrollManager{
options.UseCrBetaApi = true; options.UseCrBetaApi = true;
options.AutoDownload = false; options.AutoDownload = false;
options.RemoveFinishedDownload = false; options.RemoveFinishedDownload = false;
options.PersistQueue = false;
options.Chapters = true; options.Chapters = true;
options.Hslang = "none"; options.Hslang = "none";
options.Force = "Y"; options.Force = "Y";
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]"; options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
options.Partsize = 10; options.Partsize = 10;
options.DubDownloadDelaySeconds = 0;
options.DlSubs = new List<string>{ "en-US" }; options.DlSubs = new List<string>{ "en-US" };
options.SkipMuxing = false; options.SkipMuxing = false;
options.MkvmergeOptions = []; options.MkvmergeOptions = [];
@ -127,6 +133,8 @@ public class CrunchyrollManager{
options.CcSubsFont = "Trebuchet MS"; options.CcSubsFont = "Trebuchet MS";
options.RetryDelay = 5; options.RetryDelay = 5;
options.RetryAttempts = 5; options.RetryAttempts = 5;
options.PlaybackRateLimitRetryDelaySeconds = 30;
options.RetryMaxDelaySeconds = 3600;
options.Numbers = 2; options.Numbers = 2;
options.Timeout = 15000; options.Timeout = 15000;
options.DubLang = new List<string>(){ "ja-JP" }; options.DubLang = new List<string>(){ "ja-JP" };
@ -158,11 +166,13 @@ public class CrunchyrollManager{
}; };
options.History = true; options.History = true;
options.HistoryRemoveMissingEpisodes = true;
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases; options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
options.HistoryAutoRefreshIntervalMinutes = 0; options.HistoryAutoRefreshIntervalMinutes = 0;
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions); CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
options.NormalizeNotificationSettings();
return options; return options;
} }
@ -182,6 +192,7 @@ public class CrunchyrollManager{
CrSeries = new CrSeries(); CrSeries = new CrSeries();
CrMovies = new CrMovies(); CrMovies = new CrMovies();
CrMusic = new CrMusic(); CrMusic = new CrMusic();
CrQueue = new CrQueue();
History = new History(); History = new History();
} }
@ -236,29 +247,94 @@ public class CrunchyrollManager{
}; };
if (CrunOptions.StreamEndpoint == null){ var ghAuth = Updater.Instance.GhAuthJson;
CrunOptions.StreamEndpoint = DefaultAndroidTvAuthSettings; var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
} else if (CrunOptions.StreamEndpoint.UseDefault){ var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
CrunOptions.StreamEndpoint.Authorization = DefaultAndroidTvAuthSettings.Authorization;
CrunOptions.StreamEndpoint.UserAgent = DefaultAndroidTvAuthSettings.UserAgent; //---------- TV ----------
CrunOptions.StreamEndpoint ??= new CrAuthSettings{
Authorization = DefaultAndroidTvAuthSettings.Authorization,
UserAgent = DefaultAndroidTvAuthSettings.UserAgent,
Device_name = DefaultAndroidTvAuthSettings.Device_name,
Device_type = DefaultAndroidTvAuthSettings.Device_type,
UseDefault = true
};
if (CrunOptions.StreamEndpoint.UseDefault){
var streamEndpointAuthorization = DefaultAndroidTvAuthSettings.Authorization;
var streamEndpointUserAgent = DefaultAndroidTvAuthSettings.UserAgent;
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.Authorization) &&
!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.UserAgent) &&
Helpers.CompareClientVersions(
Helpers.ExtractClientVersion(CrunOptions.StreamEndpoint.UserAgent),
Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
streamEndpointAuthorization = CrunOptions.StreamEndpoint.Authorization;
streamEndpointUserAgent = CrunOptions.StreamEndpoint.UserAgent;
}
if (ghAuthTv != null &&
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
streamEndpointAuthorization = ghAuthTv.Authorization;
streamEndpointUserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
}
CrunOptions.StreamEndpoint.Authorization = streamEndpointAuthorization;
CrunOptions.StreamEndpoint.UserAgent = streamEndpointUserAgent;
CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name; CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name;
CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type; CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type;
} }
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv"; CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint; CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
if (CrunOptions.StreamEndpointSecondSettings == null){ //---------- TV ----------
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
} else if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
CrunOptions.StreamEndpointSecondSettings.Authorization = DefaultAndroidAuthSettings.Authorization; //---------- Mobile ----------
CrunOptions.StreamEndpointSecondSettings.UserAgent = DefaultAndroidAuthSettings.UserAgent; CrunOptions.StreamEndpointSecondSettings ??= new CrAuthSettings{
Authorization = DefaultAndroidAuthSettings.Authorization,
UserAgent = DefaultAndroidAuthSettings.UserAgent,
Device_name = DefaultAndroidAuthSettings.Device_name,
Device_type = DefaultAndroidAuthSettings.Device_type,
UseDefault = true
};
if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
var streamEndpointSecondAuthorization = DefaultAndroidAuthSettings.Authorization;
var streamEndpointSecondUserAgent = DefaultAndroidAuthSettings.UserAgent;
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.Authorization) &&
!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.UserAgent) &&
Helpers.CompareClientVersions(
Helpers.ExtractClientVersion(CrunOptions.StreamEndpointSecondSettings.UserAgent),
Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
streamEndpointSecondAuthorization = CrunOptions.StreamEndpointSecondSettings.Authorization;
streamEndpointSecondUserAgent = CrunOptions.StreamEndpointSecondSettings.UserAgent;
}
if (ghAuthMobile != null &&
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
streamEndpointSecondAuthorization = ghAuthMobile.Authorization;
streamEndpointSecondUserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
}
CrunOptions.StreamEndpointSecondSettings.Authorization = streamEndpointSecondAuthorization;
CrunOptions.StreamEndpointSecondSettings.UserAgent = streamEndpointSecondUserAgent;
CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name; CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name;
CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type; CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type;
} }
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings; CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
//---------- Mobile ----------
await CrAuthEndpoint1.Auth(); await CrAuthEndpoint1.Auth();
if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){ if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){
await CrAuthEndpoint2.Auth(); await CrAuthEndpoint2.Auth();
@ -337,15 +413,16 @@ public class CrunchyrollManager{
QueueManager.Instance.ReleaseDownloadSlot(data); QueueManager.Instance.ReleaseDownloadSlot(data);
} }
int retryAttemptCount = data.DownloadProgress.RetryAttemptCount;
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Downloading,
Error = false,
Percent = 0, Percent = 0,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Starting" Doing = "Starting",
RetryAttemptCount = retryAttemptCount
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var res = new DownloadResponse(); var res = new DownloadResponse();
try{ try{
res = await DownloadMediaList(data, options); res = await DownloadMediaList(data, options);
@ -354,18 +431,31 @@ public class CrunchyrollManager{
res.Error = true; res.Error = true;
} }
if (res.RetrySuggested){
ReleaseDownloadSlotIfHeld();
var retryDelay = TimeSpan.FromSeconds(Math.Max(1, res.RetryDelaySeconds));
QueueManager.Instance.BlockAutoDownloadUntil(retryDelay, data.Cts.Token);
QueueManager.Instance.ScheduleRetry(
data,
retryDelay,
$"Rate limited by playback API. Retrying in {(int)retryDelay.TotalSeconds}s",
data.Cts.Token);
return false;
}
data.DownloadProgress.ClearRetryState();
if (res.Error){ if (res.Error){
ReleaseDownloadSlotIfHeld(); ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = false, State = DownloadState.Error,
Error = true,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""), Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""),
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
await NotificationPublisher.Instance.PublishDownloadFailedAsync(CrunOptions.NotificationSettings, data, res.ErrorText);
return false; return false;
} }
@ -373,13 +463,13 @@ public class CrunchyrollManager{
if (options.DownloadAllowEarlyStart){ if (options.DownloadAllowEarlyStart){
ReleaseDownloadSlotIfHeld(); ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Waiting for Muxing/Encoding" Doing = "Waiting for Muxing/Encoding"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token); await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
processingSlotHeld = true; processingSlotHeld = true;
} }
@ -389,16 +479,17 @@ public class CrunchyrollManager{
bool syncError = false; bool syncError = false;
bool muxError = false; bool muxError = false;
var notSyncedDubs = ""; var notSyncedDubs = "";
var fallbackUsed = false;
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Muxing" Doing = "Muxing"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
if (options.MuxFonts){ if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync(); await FontsManager.Instance.GetFontsAsync();
@ -459,14 +550,14 @@ public class CrunchyrollManager{
if (options.IsEncodeEnabled){ if (options.IsEncodeEnabled){
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Encoding" Doing = "Encoding"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty); var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
@ -508,6 +599,52 @@ public class CrunchyrollManager{
}, },
fileNameAndPath, data); fileNameAndPath, data);
if (result.syncError && options.SyncTimingFullQualityFallback && result.failedSyncLocales.Count > 0){
var fallbackResult = await TrySyncTimingFallbackAsync(res, data, options, result.failedSyncLocales);
if (fallbackResult.FallbackReady){
fallbackUsed = true;
if (result.isMuxed && result.merger != null){
Helpers.DeleteFile(result.merger.Options.Output);
}
result = await MuxStreams(res.Data,
new CrunchyMuxOptions{
DubLangList = options.DubLang,
SubLangList = options.DlSubs,
FfmpegOptions = options.FfmpegOptions,
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath,
Mp4 = options.Mp4,
Mp3 = options.AudioOnlyToMp3,
MuxFonts = options.MuxFonts,
MuxCover = options.MuxCover,
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions,
ForceMuxer = options.Force,
SyncTiming = false,
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
DlVideoOnce = false,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
SignsSubsAsForced = options.SignsSubsAsForced,
},
fileNameAndPath, data);
syncError = false;
notSyncedDubs = "";
} else{
result.notSyncedDubs = string.Join(", ", fallbackResult.FailedLocales);
}
}
syncError = result.syncError; syncError = result.syncError;
notSyncedDubs = result.notSyncedDubs; notSyncedDubs = result.notSyncedDubs;
muxError = !result.isMuxed && !data.OnlySubs; muxError = !result.isMuxed && !data.OnlySubs;
@ -516,16 +653,18 @@ public class CrunchyrollManager{
result.merger.CleanUp(); result.merger.CleanUp();
} }
DeleteSyncVideoFiles(res.Data);
if (options.IsEncodeEnabled && !muxError){ if (options.IsEncodeEnabled && !muxError){
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Encoding" Doing = "Encoding"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty); var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data); if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
@ -543,22 +682,22 @@ public class CrunchyrollManager{
Language = d.Language, Language = d.Language,
ClosedCaption = d.Cc ?? false, ClosedCaption = d.Cc ?? false,
Signs = d.Signs ?? false, Signs = d.Signs ?? false,
Delay = d.Delay,
RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia
}) })
.ToList(); .ToList();
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles); await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed);
} }
} }
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Done,
Done = true,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") + (fallbackUsed ? " - Used full-quality fallback" : "")
}; };
QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError); QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError);
@ -577,8 +716,7 @@ public class CrunchyrollManager{
} }
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Done,
Done = true,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
@ -598,7 +736,7 @@ public class CrunchyrollManager{
} }
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){ if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
var ids = data.Data.First().GetOriginalIds(); var ids = data.Data.First().GetOriginalIds();
@ -609,31 +747,14 @@ public class CrunchyrollManager{
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId); _ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
} }
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){ await NotificationPublisher.Instance.PublishDownloadFinishedAsync(CrunOptions.NotificationSettings, data);
if (CrunOptions.DownloadFinishedPlaySound){
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
}
if (CrunOptions.DownloadFinishedExecute){ if (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
try{ await NotificationPublisher.Instance.PublishQueueFinishedAsync(CrunOptions.NotificationSettings, data);
var filePath = CrunOptions.DownloadFinishedExecutePath;
if (!string.IsNullOrEmpty(filePath)){
Helpers.ExecuteFile(filePath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to execute file: " + exception);
}
}
if (CrunOptions.ShutdownWhenQueueEmpty){ if (CrunOptions.ShutdownWhenQueueEmpty){
CrunOptions.ShutdownWhenQueueEmpty = false;
CfgManager.WriteCrSettings();
Helpers.ShutdownComputer(); Helpers.ShutdownComputer();
} }
} }
@ -644,18 +765,18 @@ public class CrunchyrollManager{
#region Temp Files Move #region Temp Files Move
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles){ private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles, bool replaceExisting = false){
if (!options.DownloadToTempFolder) return; if (!options.DownloadToTempFolder) return;
data.DownloadProgress = new DownloadProgress{ data.DownloadProgress = new DownloadProgress{
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Moving Files" Doing = "Moving Files"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){ if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
Console.WriteLine("Invalid or non-existent temp folder path."); Console.WriteLine("Invalid or non-existent temp folder path.");
@ -663,15 +784,15 @@ public class CrunchyrollManager{
} }
// Move the main output file // Move the main output file
await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options); await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
// Move the subtitle files // Move the subtitle files
foreach (var downloadedMedia in subtitles){ foreach (var downloadedMedia in subtitles){
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options); await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
} }
} }
private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options){ private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options, bool replaceExisting = false){
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)){ if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)){
// Console.Error.WriteLine("Source file does not exist or path is invalid."); // Console.Error.WriteLine("Source file does not exist or path is invalid.");
return; return;
@ -691,6 +812,7 @@ public class CrunchyrollManager{
: CfgManager.PathVIDEOS_DIR; : CfgManager.PathVIDEOS_DIR;
var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName); var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName);
destinationPath = replaceExisting ? destinationPath : Helpers.GetAvailableDestinationPath(destinationPath);
string? destinationDirectory = Path.GetDirectoryName(destinationPath); string? destinationDirectory = Path.GetDirectoryName(destinationPath);
if (string.IsNullOrEmpty(destinationDirectory)){ if (string.IsNullOrEmpty(destinationDirectory)){
@ -704,7 +826,7 @@ public class CrunchyrollManager{
} }
}); });
await Task.Run(() => File.Move(sourcePath, destinationPath)); await Task.Run(() => File.Move(sourcePath, destinationPath, replaceExisting));
Console.WriteLine($"File moved to {destinationPath}"); Console.WriteLine($"File moved to {destinationPath}");
} catch (IOException ex){ } catch (IOException ex){
Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}"); Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}");
@ -717,7 +839,8 @@ public class CrunchyrollManager{
#endregion #endregion
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename, CrunchyEpMeta crunchyEpMeta){ private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List<string> failedSyncLocales)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename,
CrunchyEpMeta crunchyEpMeta){
var muxToMp3 = false; var muxToMp3 = false;
if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){ if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){
@ -728,7 +851,7 @@ public class CrunchyrollManager{
} }
} else{ } else{
Console.WriteLine("Skip muxing since no videos are downloaded"); Console.WriteLine("Skip muxing since no videos are downloaded");
return (null, false, false, ""); return (null, false, false, "", []);
} }
} }
@ -771,10 +894,10 @@ public class CrunchyrollManager{
OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(), OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
SkipSubMux = options.SkipSubMux, SkipSubMux = options.SkipSubMux,
OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(), { Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, Delay = a.Delay, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}", Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}",
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(), { File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, Delay = a.Delay, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
KeepAllVideos = options.KeepAllVideos, KeepAllVideos = options.KeepAllVideos,
Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) : [], Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) : [],
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(), Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
@ -797,11 +920,14 @@ public class CrunchyrollManager{
Cover = options.MuxCover ? data.Where(a => a.Type == DownloadMediaType.Cover).Select(a => new MergerInput{ Path = a.Path ?? string.Empty }).ToList() : [], Cover = options.MuxCover ? data.Where(a => a.Type == DownloadMediaType.Cover).Select(a => new MergerInput{ Path = a.Path ?? string.Empty }).ToList() : [],
}); });
if (!File.Exists(CfgManager.PathFFMPEG)){ bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
if (!ffmpegAvailable){
Console.Error.WriteLine("FFmpeg not found"); Console.Error.WriteLine("FFmpeg not found");
} }
if (!File.Exists(CfgManager.PathMKVMERGE)){ if (!mkvmergeAvailable){
Console.Error.WriteLine("MKVmerge not found"); Console.Error.WriteLine("MKVmerge not found");
} }
@ -811,14 +937,14 @@ public class CrunchyrollManager{
if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){ if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){
crunchyEpMeta.DownloadProgress = new DownloadProgress(){ crunchyEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Muxing Syncing Dub Timings" Doing = "Muxing Syncing Dub Timings"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var basePath = merger.Options.OnlyVid.First().Path; var basePath = merger.Options.OnlyVid.First().Path;
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList(); var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
@ -845,42 +971,162 @@ public class CrunchyrollManager{
var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale); var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale);
audio?.Delay = (int)(delay.offSet * 1000); var delayMs = (int)(delay.offSet * 1000);
if (audio != null){
audio.Delay = delayMs;
}
var sourceAudio = data.FirstOrDefault(downloadedMedia =>
downloadedMedia.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription &&
downloadedMedia.Lang.CrLocale == syncVideo.Lang.CrLocale);
if (sourceAudio != null){
sourceAudio.Delay = delayMs;
}
var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList(); var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
var sourceSubtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle && a.RelatedVideoDownloadMedia == syncVideo).ToList();
if (subtitles.Count <= 0) continue; if (subtitles.Count <= 0) continue;
foreach (var subMergerInput in subtitles){ foreach (var subMergerInput in subtitles){
subMergerInput.Delay = (int)(delay.offSet * 1000); subMergerInput.Delay = delayMs;
}
foreach (var sourceSubtitle in sourceSubtitles){
sourceSubtitle.Delay = delayMs;
} }
} }
} }
} }
syncVideosList.ForEach(syncVideo => {
if (syncVideo.Path != null) Helpers.DeleteFile(syncVideo.Path);
});
crunchyEpMeta.DownloadProgress = new DownloadProgress(){ crunchyEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Processing,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Muxing" Doing = "Muxing"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
} }
if (!options.Mp4 && !muxToMp3){ if (!options.Mp4 && !muxToMp3){
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE); isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE, crunchyEpMeta.Cts.Token);
} else{ } else{
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG); isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG, crunchyEpMeta.Cts.Token);
} }
return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs)); return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs), notSyncedDubs);
}
private async Task<(bool FallbackReady, List<string> FailedLocales)> TrySyncTimingFallbackAsync(DownloadResponse res, CrunchyEpMeta data, CrDownloadOptions options, List<string> failedSyncLocales){
var uniqueFailedLocales = failedSyncLocales
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (uniqueFailedLocales.Count == 0){
return (false, []);
}
data.DownloadProgress = new DownloadProgress{
State = DownloadState.Downloading,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0,
Doing = $"Downloading full-quality fallback video ({string.Join(", ", uniqueFailedLocales)})"
};
QueueManager.Instance.RefreshQueue();
foreach (var syncVideo in res.Data.Where(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase)).ToList()){
if (!string.IsNullOrEmpty(syncVideo.Path)){
Helpers.DeleteFile(syncVideo.Path);
Helpers.DeleteFile(syncVideo.Path + ".resume");
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
}
}
res.Data.RemoveAll(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
var fallbackOptions = Helpers.DeepCopy(options) ?? new CrDownloadOptions();
fallbackOptions.DubLang = uniqueFailedLocales;
fallbackOptions.Noaudio = true;
fallbackOptions.Novids = false;
fallbackOptions.DlSubs = [];
fallbackOptions.SkipSubs = true;
fallbackOptions.SkipSubsMux = true;
fallbackOptions.Chapters = false;
fallbackOptions.IncludeVideoDescription = false;
fallbackOptions.MuxCover = false;
fallbackOptions.DownloadDescriptionAudio = false;
fallbackOptions.DlVideoOnce = false;
fallbackOptions.SyncTiming = false;
fallbackOptions.DownloadFirstAvailableDub = false;
fallbackOptions.SkipMuxing = true;
var originalDataEntries = data.Data;
var originalSelectedDubs = data.SelectedDubs != null ? new List<string>(data.SelectedDubs) : null;
var originalDownloadSubs = new List<string>(data.DownloadSubs);
var originalTempFileSuffix = data.TempFileSuffix;
data.Data = data.Data
.Where(epMeta => epMeta.Lang != null && uniqueFailedLocales.Contains(epMeta.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
.ToList();
data.SelectedDubs = uniqueFailedLocales;
data.DownloadSubs = [];
data.TempFileSuffix = $"-fallback-{Guid.NewGuid():N}"[..19];
if (data.Data.Count == 0){
data.Data = originalDataEntries;
data.SelectedDubs = originalSelectedDubs;
data.DownloadSubs = originalDownloadSubs;
data.TempFileSuffix = originalTempFileSuffix;
return (false, uniqueFailedLocales);
}
DownloadResponse fallbackResponse;
try{
fallbackResponse = await DownloadMediaList(data, fallbackOptions);
} finally{
data.Data = originalDataEntries;
data.SelectedDubs = originalSelectedDubs;
data.DownloadSubs = originalDownloadSubs;
data.TempFileSuffix = originalTempFileSuffix;
}
if (fallbackResponse.Error){
return (false, uniqueFailedLocales);
}
var fallbackVideos = fallbackResponse.Data
.Where(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
.ToList();
var fallbackVideoLocales = fallbackVideos
.Select(media => media.Lang.CrLocale)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (fallbackVideoLocales.Count != uniqueFailedLocales.Count){
return (false, uniqueFailedLocales);
}
res.Data.RemoveAll(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
res.Data.AddRange(fallbackVideos);
return (true, uniqueFailedLocales);
}
private static void DeleteSyncVideoFiles(List<DownloadedMedia> data){
foreach (var syncVideo in data.Where(media => media.Type == DownloadMediaType.SyncVideo).ToList()){
if (!string.IsNullOrEmpty(syncVideo.Path)){
Helpers.DeleteFile(syncVideo.Path);
Helpers.DeleteFile(syncVideo.Path + ".resume");
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
}
}
} }
private async Task<DownloadResponse> DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){ private async Task<DownloadResponse> DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){
@ -894,8 +1140,11 @@ public class CrunchyrollManager{
}; };
} }
bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
if (!File.Exists(CfgManager.PathFFMPEG)){ if (!ffmpegAvailable){
Console.Error.WriteLine("Missing ffmpeg"); Console.Error.WriteLine("Missing ffmpeg");
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg", MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
"https://github.com/GyanD/codexffmpeg/releases/latest"); "https://github.com/GyanD/codexffmpeg/releases/latest");
@ -907,7 +1156,7 @@ public class CrunchyrollManager{
}; };
} }
if (!File.Exists(CfgManager.PathMKVMERGE)){ if (!mkvmergeAvailable){
Console.Error.WriteLine("Missing Mkvmerge"); Console.Error.WriteLine("Missing Mkvmerge");
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge", MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
"https://mkvtoolnix.download/downloads.html#windows"); "https://mkvtoolnix.download/downloads.html#windows");
@ -919,7 +1168,7 @@ public class CrunchyrollManager{
}; };
} }
} else{ } else{
if (!Helpers.IsInstalled("ffmpeg", "-version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "ffmpeg"))){ if (!ffmpegAvailable){
Console.Error.WriteLine("Ffmpeg is not installed or not in the system PATH."); Console.Error.WriteLine("Ffmpeg is not installed or not in the system PATH.");
MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH."); MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH.");
return new DownloadResponse{ return new DownloadResponse{
@ -930,7 +1179,7 @@ public class CrunchyrollManager{
}; };
} }
if (!Helpers.IsInstalled("mkvmerge", "--version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "mkvmerge"))){ if (!mkvmergeAvailable){
Console.Error.WriteLine("Mkvmerge is not installed or not in the system PATH."); Console.Error.WriteLine("Mkvmerge is not installed or not in the system PATH.");
MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH."); MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH.");
return new DownloadResponse{ return new DownloadResponse{
@ -991,11 +1240,11 @@ public class CrunchyrollManager{
if (data.Data is{ Count: > 0 }){ if (data.Data is{ Count: > 0 }){
options.Partsize = options.Partsize > 0 ? options.Partsize : 1; options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty ,data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty); var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty, data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
SonarrEpisode? sonarrEpisode; SonarrEpisode? sonarrEpisode;
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){ if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId)); sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId));
if(sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId); if (sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true)); variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true));
variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true)); variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true)); variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
@ -1044,7 +1293,16 @@ public class CrunchyrollManager{
data.Data = sortedMetaData; data.Data = sortedMetaData;
var epMetaIndex = 0;
foreach (CrunchyEpMetaData epMeta in data.Data){ foreach (CrunchyEpMetaData epMeta in data.Data){
if (epMetaIndex > 0 && options.DubDownloadDelaySeconds > 0){
var delay = TimeSpan.FromSeconds(options.DubDownloadDelaySeconds);
data.DownloadProgress.Doing = $"Waiting {options.DubDownloadDelaySeconds}s before next dub";
QueueManager.Instance.RefreshQueue();
await Task.Delay(delay, data.Cts.Token);
}
epMetaIndex++;
Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}"); Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}");
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId); string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
@ -1121,8 +1379,8 @@ public class CrunchyrollManager{
#endregion #endregion
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData = default; (bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData = default;
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default; (bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData2 = default;
if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){ if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){
fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint); fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint);
@ -1133,7 +1391,9 @@ public class CrunchyrollManager{
} }
if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){ if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){
var errorJson = fetchPlaybackData.error; var errorJson = !string.IsNullOrEmpty(fetchPlaybackData.error)
? fetchPlaybackData.error
: fetchPlaybackData2.error;
if (!string.IsNullOrEmpty(errorJson)){ if (!string.IsNullOrEmpty(errorJson)){
var error = StreamError.FromJson(errorJson); var error = StreamError.FromJson(errorJson);
@ -1147,7 +1407,7 @@ public class CrunchyrollManager{
}; };
} }
if (error?.Error.Contains("Account maturity rating is lower than video rating") == true || if (error?.Error?.Contains("Account maturity rating is lower than video rating") == true ||
errorJson.Contains("Account maturity rating is lower than video rating")){ errorJson.Contains("Account maturity rating is lower than video rating")){
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings"); MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
return new DownloadResponse{ return new DownloadResponse{
@ -1158,6 +1418,26 @@ public class CrunchyrollManager{
}; };
} }
if (error?.IsPlaybackRateLimitError() == true){
int retryDelaySeconds = Helpers.GetRetryDelaySeconds(options, data.DownloadProgress.RetryAttemptCount);
if (fetchPlaybackData.Headers != null &&
fetchPlaybackData.Headers.TryGetValue("retry-after", out var retryAfter) &&
int.TryParse(retryAfter, out var parsedRetryAfter)){
Console.WriteLine($"Retry after: {parsedRetryAfter} seconds");
retryDelaySeconds = parsedRetryAfter;
}
Console.Error.WriteLine($"Playback API rate limited with 4294. Requeueing download in {retryDelaySeconds}s.");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
RetrySuggested = true,
RetryDelaySeconds = retryDelaySeconds,
FileName = "./unknown",
ErrorText = "Rate limit error"
};
}
if (!string.IsNullOrEmpty(error?.Error)){ if (!string.IsNullOrEmpty(error?.Error)){
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}"); MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
return new DownloadResponse{ return new DownloadResponse{
@ -1652,7 +1932,8 @@ public class CrunchyrollManager{
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : ""); string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
string tempFile = Path.Combine(FileNameManager string tempFile = Path.Combine(FileNameManager
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}", variables, options.Numbers, options.FileNameWhitespaceSubstitute, .ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}{data.TempFileSuffix ?? string.Empty}", variables, options.Numbers,
options.FileNameWhitespaceSubstitute,
options.Override) options.Override)
.ToArray()); .ToArray());
string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile); string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile);
@ -1743,13 +2024,13 @@ public class CrunchyrollManager{
if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){ if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Downloading,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Decrypting" Doing = "Decrypting"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
Console.WriteLine("Decryption Needed, attempting to decrypt"); Console.WriteLine("Decryption Needed, attempting to decrypt");
@ -1834,13 +2115,13 @@ public class CrunchyrollManager{
if (videoDownloaded){ if (videoDownloaded){
Console.WriteLine("Started decrypting video"); Console.WriteLine("Started decrypting video");
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Downloading,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Decrypting video" Doing = "Decrypting video"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt, var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandVideo, tempTsFileWorkDir); commandVideo, tempTsFileWorkDir);
@ -1864,18 +2145,8 @@ public class CrunchyrollManager{
Console.WriteLine("Decryption done for video"); Console.WriteLine("Decryption done for video");
if (!options.Nocleanup){ if (!options.Nocleanup){
try{ Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s");
if (File.Exists($"{tempTsFile}.video.enc.m4s")){ Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s.resume");
File.Delete($"{tempTsFile}.video.enc.m4s");
}
if (File.Exists($"{tempTsFile}.video.enc.m4s.resume")){
File.Delete($"{tempTsFile}.video.enc.m4s.resume");
}
} catch (Exception ex){
Console.WriteLine($"Failed to delete file {tempTsFile}.video.enc.m4s. Error: {ex.Message}");
// Handle exceptions if you need to log them or throw
}
} }
try{ try{
@ -1905,13 +2176,13 @@ public class CrunchyrollManager{
if (audioDownloaded){ if (audioDownloaded){
Console.WriteLine("Started decrypting audio"); Console.WriteLine("Started decrypting audio");
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Downloading,
Percent = 100, Percent = 100,
Time = 0, Time = 0,
DownloadSpeedBytes = 0, DownloadSpeedBytes = 0,
Doing = "Decrypting audio" Doing = "Decrypting audio"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt, var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandAudio, tempTsFileWorkDir); commandAudio, tempTsFileWorkDir);
@ -1935,18 +2206,8 @@ public class CrunchyrollManager{
Console.WriteLine("Decryption done for audio"); Console.WriteLine("Decryption done for audio");
if (!options.Nocleanup){ if (!options.Nocleanup){
try{ Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s");
if (File.Exists($"{tempTsFile}.audio.enc.m4s")){ Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s.resume");
File.Delete($"{tempTsFile}.audio.enc.m4s");
}
if (File.Exists($"{tempTsFile}.audio.enc.m4s.resume")){
File.Delete($"{tempTsFile}.audio.enc.m4s.resume");
}
} catch (Exception ex){
Console.WriteLine($"Failed to delete file {tempTsFile}.audio.enc.m4s. Error: {ex.Message}");
// Handle exceptions if you need to log them or throw
}
} }
try{ try{
@ -2480,7 +2741,7 @@ public class CrunchyrollManager{
#region Fetch Playback Data #region Fetch Playback Data
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc, private async Task<(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc,
CrAuthSettings optionsStreamEndpointSettings){ CrAuthSettings optionsStreamEndpointSettings){
var temppbData = new PlaybackData{ var temppbData = new PlaybackData{
Total = 0, Total = 0,
@ -2514,16 +2775,17 @@ public class CrunchyrollManager{
} }
} }
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent); return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent, playbackRequestResponse.Headers);
} }
private async Task<(bool IsOk, string ResponseContent, string error)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){ private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){
var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null); var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null);
request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent); request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent);
return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore); return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore);
} }
private async Task<(bool IsOk, string ResponseContent, string error)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent, string error) response, string endpoint, CrAuth authEndpoint){ private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> HandleStreamErrorsAsync(
(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers) response, string endpoint, CrAuth authEndpoint){
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response; if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
var error = StreamError.FromJson(response.ResponseContent); var error = StreamError.FromJson(response.ResponseContent);

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized; using System.Collections.Specialized;
@ -18,6 +18,7 @@ using CRD.Utils.Sonarr;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using CRD.ViewModels; using CRD.ViewModels;
using CRD.ViewModels.Utils; using CRD.ViewModels.Utils;
using CRD.Views.Utils; using CRD.Views.Utils;
@ -91,6 +92,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _syncTimings; private bool _syncTimings;
[ObservableProperty]
private bool _syncTimingsFullQualityFallback;
[ObservableProperty] [ObservableProperty]
private bool _defaultSubSigns; private bool _defaultSubSigns;
@ -115,6 +119,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private double? _partSize; private double? _partSize;
[ObservableProperty]
private double? _dubDownloadDelaySeconds;
[ObservableProperty] [ObservableProperty]
private string _fileName = ""; private string _fileName = "";
@ -352,6 +359,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _markAsWatched; private bool _markAsWatched;
public string FontMuxDisclaimer =>
$"Crunchyroll no longer provides the old libass font files. Font muxing now uses installed system fonts and custom files from {CfgManager.PathFONTS_DIR}. If subtitles still report missing fonts, add those files there manually.";
private bool settingsLoaded; private bool settingsLoaded;
public CrunchyrollSettingsViewModel(){ public CrunchyrollSettingsViewModel(){
@ -467,6 +477,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay; DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
DefaultSubSigns = options.DefaultSubSigns; DefaultSubSigns = options.DefaultSubSigns;
PartSize = options.Partsize; PartSize = options.Partsize;
DubDownloadDelaySeconds = options.DubDownloadDelaySeconds;
IncludeEpisodeDescription = options.IncludeVideoDescription; IncludeEpisodeDescription = options.IncludeVideoDescription;
FileTitle = options.VideoTitle ?? ""; FileTitle = options.VideoTitle ?? "";
IncludeSignSubs = options.IncludeSignsSubs; IncludeSignSubs = options.IncludeSignsSubs;
@ -483,6 +494,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
MuxTypesettingFonts = options.MuxTypesettingFonts; MuxTypesettingFonts = options.MuxTypesettingFonts;
MuxCover = options.MuxCover; MuxCover = options.MuxCover;
SyncTimings = options.SyncTiming; SyncTimings = options.SyncTiming;
SyncTimingsFullQualityFallback = options.SyncTimingFullQualityFallback;
SkipSubMux = options.SkipSubsMux; SkipSubMux = options.SkipSubsMux;
LeadingNumbers = options.Numbers; LeadingNumbers = options.Numbers;
FileName = options.FileName; FileName = options.FileName;
@ -559,6 +571,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts; CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover; CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings; CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
CrunchyrollManager.Instance.CrunOptions.SyncTimingFullQualityFallback = SyncTimingsFullQualityFallback;
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux; CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10); CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);
CrunchyrollManager.Instance.CrunOptions.FileName = FileName; CrunchyrollManager.Instance.CrunOptions.FileName = FileName;
@ -566,6 +579,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs; CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs; CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000); CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000);
CrunchyrollManager.Instance.CrunOptions.DubDownloadDelaySeconds = Math.Max((int)(DubDownloadDelaySeconds ?? 0), 0);
CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic; CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic;
CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection(); CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
@ -776,6 +790,17 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public void ResetEndpointSettings(){ public void ResetEndpointSettings(){
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings; var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
var ghAuth = Updater.Instance.GhAuthJson;
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
if (ghAuthMobile != null &&
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
defaultSettings.Authorization = ghAuthMobile.Authorization;
defaultSettings.UserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
}
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null; ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0]; SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
@ -792,6 +817,15 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public void ResetFirstEndpointSettings(){ public void ResetFirstEndpointSettings(){
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings; var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings;
var ghAuth = Updater.Instance.GhAuthJson;
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
if (ghAuthTv != null &&
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
defaultSettings.Authorization = ghAuthTv.Authorization;
defaultSettings.UserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
}
ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null; ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0]; SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];

View file

@ -255,6 +255,17 @@
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Dub Download Delay"
Description="Delay in seconds before starting the next selected dub. 0 disables the delay.">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0"
Value="{Binding DubDownloadDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Stream Endpoint "> <controls:SettingsExpanderItem Content="Stream Endpoint ">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<StackPanel> <StackPanel>
@ -585,6 +596,12 @@
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox> <CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox>
<CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox> <CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox>
<TextBlock IsVisible="{Binding MuxFonts}"
MaxWidth="360"
Margin="0,6,0,0"
Opacity="0.85"
TextWrapping="Wrap"
Text="{Binding FontMuxDisclaimer}" />
</StackPanel> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
@ -634,6 +651,15 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="8"
IsVisible="{Binding SyncTimings}">
<TextBlock VerticalAlignment="Center"
Text="Mux failed dubs in selected quality" />
<CheckBox VerticalAlignment="Center"
IsChecked="{Binding SyncTimingsFullQualityFallback}" />
</StackPanel>
</StackPanel> </StackPanel>

View file

@ -59,31 +59,59 @@ public class History{
if (parsedSeries.Data != null){ if (parsedSeries.Data != null){
var result = false; var result = false;
foreach (var s in parsedSeries.Data){ foreach (var s in parsedSeries.Data){
var sId = s.Id; var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
if (s.Versions is{ Count: > 0 }){ ? crunInstance.DefaultLocale
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){ : crunInstance.CrunOptions.HistoryLang;
if (sVersion.Guid != null){
sId = sVersion.Guid;
}
break; var candidateIds = new List<string>();
}
if (s.Versions is{ Count: > 0 }){
candidateIds.AddRange(
s.Versions
.Where(v => v.Original == true && !string.IsNullOrWhiteSpace(v.Guid))
.OrderByDescending(v => v.Guid!.Length)
.Select(v => v.Guid!)
);
} }
if (!string.IsNullOrEmpty(seasonId) && sId != seasonId) continue; if (!string.IsNullOrWhiteSpace(s.Id)){
candidateIds.Add(s.Id);
}
candidateIds = candidateIds
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(sId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true); if (!string.IsNullOrEmpty(seasonId) &&
!candidateIds.Contains(seasonId, StringComparer.OrdinalIgnoreCase)){
continue;
}
if (seasonData.Data is{ Count: > 0 }){ foreach (var candidateId in candidateIds){
result = true; try{
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>()); var seasonData = await crunInstance.CrSeries.GetSeasonDataById(candidateId, lang, true);
if (seasonData.Data is{ Count: > 0 }){
result = true;
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
break;
}
} catch{
// optional: log candidateId
}
} }
} }
historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
if (historySeries != null){ if (historySeries != null){
RemoveUnavailableEpisodes(historySeries);
if (historySeries.Seasons.Count == 0){
crunInstance.HistoryList.Remove(historySeries);
CfgManager.UpdateHistoryFile();
return result;
}
MatchHistorySeriesWithSonarr(false); MatchHistorySeriesWithSonarr(false);
await MatchHistoryEpisodesWithSonarr(false, historySeries); await MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile(); CfgManager.UpdateHistoryFile();
@ -263,10 +291,7 @@ public class History{
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
if (historySeries != null){ if (historySeries != null){
historySeries.HistorySeriesAddDate ??= DateTime.Now; historySeries.HistorySeriesAddDate ??= DateTime.Now;
historySeries.SeriesType = firstEpisode.GetSeriesType();
historySeries.SeriesStreamingService = StreamingService.Crunchyroll; historySeries.SeriesStreamingService = StreamingService.Crunchyroll;
await RefreshSeriesData(seriesId, historySeries);
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId()); var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId());
if (historySeason != null){ if (historySeason != null){
@ -307,6 +332,7 @@ public class History{
historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum(); historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum();
historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate(); historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate();
historyEpisode.EpisodeType = historySource.GetEpisodeType(); historyEpisode.EpisodeType = historySource.GetEpisodeType();
historyEpisode.EpisodeSeriesType = historySource.GetSeriesType();
historyEpisode.IsEpisodeAvailableOnStreamingService = true; historyEpisode.IsEpisodeAvailableOnStreamingService = true;
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl(); historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
@ -325,6 +351,9 @@ public class History{
newSeason.Init(); newSeason.Init();
} }
historySeries.SeriesType = InferSeriesType(historySeries);
await RefreshSeriesData(seriesId, historySeries);
_ = historySeries.LoadImage();
historySeries.UpdateNewEpisodes(); historySeries.UpdateNewEpisodes();
} else if (!string.IsNullOrEmpty(seriesId)){ } else if (!string.IsNullOrEmpty(seriesId)){
historySeries = new HistorySeries{ historySeries = new HistorySeries{
@ -332,7 +361,7 @@ public class History{
SeriesId = firstEpisode.GetSeriesId(), SeriesId = firstEpisode.GetSeriesId(),
Seasons = [], Seasons = [],
HistorySeriesAddDate = DateTime.Now, HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(), SeriesType = SeriesType.Unknown,
SeriesStreamingService = StreamingService.Crunchyroll SeriesStreamingService = StreamingService.Crunchyroll
}; };
crunInstance.HistoryList.Add(historySeries); crunInstance.HistoryList.Add(historySeries);
@ -341,9 +370,10 @@ public class History{
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer()); newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
await RefreshSeriesData(seriesId, historySeries);
historySeries.Seasons.Add(newSeason); historySeries.Seasons.Add(newSeason);
historySeries.SeriesType = InferSeriesType(historySeries);
await RefreshSeriesData(seriesId, historySeries);
_ = historySeries.LoadImage();
historySeries.UpdateNewEpisodes(); historySeries.UpdateNewEpisodes();
historySeries.Init(); historySeries.Init();
newSeason.Init(); newSeason.Init();
@ -515,7 +545,7 @@ public class History{
private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){ private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){
if (cachedSeries == null || (!string.IsNullOrEmpty(cachedSeries.SeriesId) && cachedSeries.SeriesId != seriesId)){ if (cachedSeries == null || (!string.IsNullOrEmpty(cachedSeries.SeriesId) && cachedSeries.SeriesId != seriesId)){
if (historySeries.SeriesType == SeriesType.Series){ if (historySeries.SeriesType is SeriesType.Series or SeriesType.Movie){
var seriesData = await crunInstance.CrSeries.SeriesById(seriesId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true); var seriesData = await crunInstance.CrSeries.SeriesById(seriesId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
if (seriesData is{ Data: not null }){ if (seriesData is{ Data: not null }){
var firstEpisode = seriesData.Data.First(); var firstEpisode = seriesData.Data.First();
@ -658,6 +688,31 @@ public class History{
return null; return null;
} }
private static SeriesType InferSeriesType(HistorySeries? historySeries){
var seriesTypes = new List<SeriesType>();
if (historySeries != null){
seriesTypes.AddRange(historySeries.Seasons
.SelectMany(season => season.EpisodesList)
.Select(episode => episode.EpisodeSeriesType)
.Where(type => type != SeriesType.Unknown));
}
if (seriesTypes.Count == 0){
return historySeries?.SeriesType ?? SeriesType.Unknown;
}
if (seriesTypes.All(type => type == SeriesType.Artist)){
return SeriesType.Artist;
}
if (seriesTypes.All(type => type == SeriesType.Movie)){
return SeriesType.Movie;
}
return SeriesType.Series;
}
private string GetSeriesThumbnail(CrSeriesBase series){ private string GetSeriesThumbnail(CrSeriesBase series){
// var series = await crunInstance.CrSeries.SeriesById(seriesId); // var series = await crunInstance.CrSeries.SeriesById(seriesId);
@ -670,6 +725,39 @@ public class History{
return ""; return "";
} }
private void RemoveUnavailableEpisodes(HistorySeries historySeries){
if (!crunInstance.CrunOptions.HistoryRemoveMissingEpisodes){
return;
}
var seasonsToRemove = new List<HistorySeason>();
foreach (var season in historySeries.Seasons){
var unavailableEpisodes = season.EpisodesList
.Where(episode => !episode.IsEpisodeAvailableOnStreamingService)
.ToList();
foreach (var episode in unavailableEpisodes){
season.EpisodesList.Remove(episode);
}
if (season.EpisodesList.Count == 0){
seasonsToRemove.Add(season);
continue;
}
season.EpisodesList.Sort(new NumericStringPropertyComparer());
season.UpdateDownloaded();
}
foreach (var season in seasonsToRemove){
historySeries.Seasons.Remove(season);
}
historySeries.UpdateNewEpisodes();
SortSeasons(historySeries);
}
private HistorySeason NewHistorySeason(List<IHistorySource> episodeList, IHistorySource firstEpisode){ private HistorySeason NewHistorySeason(List<IHistorySource> episodeList, IHistorySource firstEpisode){
var newSeason = new HistorySeason{ var newSeason = new HistorySeason{
@ -696,6 +784,7 @@ public class History{
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(), HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(), EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
EpisodeType = historySource.GetEpisodeType(), EpisodeType = historySource.GetEpisodeType(),
EpisodeSeriesType = historySource.GetSeriesType(),
IsEpisodeAvailableOnStreamingService = true, IsEpisodeAvailableOnStreamingService = true,
ThumbnailImageUrl = historySource.GetImageUrl(), ThumbnailImageUrl = historySource.GetImageUrl(),
}; };
@ -775,7 +864,7 @@ public class History{
List<HistoryEpisode> failedEpisodes = []; List<HistoryEpisode> failedEpisodes = [];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => { Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){ if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) || rematchAll){
// Create a copy of the episodes list for each thread // Create a copy of the episodes list for each thread
var episodesCopy = new List<SonarrEpisode>(episodes); var episodesCopy = new List<SonarrEpisode>(episodes);

View file

@ -15,6 +15,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Notifications;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
@ -54,12 +55,14 @@ public sealed partial class ProgramManager : ObservableObject{
#region Startup Param Variables #region Startup Param Variables
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>(); private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
bool historyRefreshAdded = false; bool historyRefreshAdded;
private bool exitOnTaskFinish; private bool exitOnTaskFinish;
#endregion #endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner; private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
private bool historyRefreshNotificationsArmed;
private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10);
public IStorageProvider? StorageProvider; public IStorageProvider? StorageProvider;
@ -100,7 +103,6 @@ public sealed partial class ProgramManager : ObservableObject{
internal async Task RefreshHistory(FilterType filterType){ internal async Task RefreshHistory(FilterType filterType){
FetchingData = true; FetchingData = true;
List<HistorySeries> filteredItems; List<HistorySeries> filteredItems;
var historyList = CrunchyrollManager.Instance.HistoryList; var historyList = CrunchyrollManager.Instance.HistoryList;
@ -149,6 +151,7 @@ public sealed partial class ProgramManager : ObservableObject{
FetchingData = false; FetchingData = false;
CrunchyrollManager.Instance.History.SortItems(); CrunchyrollManager.Instance.History.SortItems();
await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance);
} }
private async Task AddMissingToQueue(){ private async Task AddMissingToQueue(){
@ -158,7 +161,7 @@ public sealed partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){ while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
Console.WriteLine("Waiting for downloads to complete..."); Console.WriteLine("Waiting for downloads to complete...");
await Task.Delay(2000); await Task.Delay(2000);
} }
@ -186,23 +189,27 @@ public sealed partial class ProgramManager : ObservableObject{
return; return;
} }
var tasks = crunchyManager.HistoryList if (crunOptions.HistoryAutoRefreshAddToQueue){
.Select(item => item.AddNewMissingToDownloads(true)); var tasks = crunchyManager.HistoryList
.Select(item => item.AddNewMissingToDownloads(true));
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
}
if (Application.Current is App app){ if (Application.Current is App app){
Dispatcher.UIThread.Post(app.UpdateTrayTooltip); Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
} }
historyRefreshNotificationsArmed = true;
} }
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){ internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes( var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang, string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
2000, null, true); 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){ var newEpisodes = newEpisodesBase?.Data ?? [];
var newEpisodes = newEpisodesBase.Data ?? [];
if (newEpisodesBase is{ Data.Count: > 0 }){
try{ try{
await crunchyManager.History.UpdateWithEpisode(newEpisodes); await crunchyManager.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile(); CfgManager.UpdateHistoryFile();
@ -210,6 +217,114 @@ public sealed partial class ProgramManager : ObservableObject{
Console.Error.WriteLine("Failed to update History: " + e.Message); Console.Error.WriteLine("Failed to update History: " + e.Message);
} }
} }
await PublishTrackedSeriesReleaseNotificationsAsync(crunchyManager, newEpisodes);
}
private async Task PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager crunchyManager, List<CrBrowseEpisode>? releaseFeedEpisodes = null){
var currentCheckTimeUtc = DateTime.UtcNow;
var settings = crunchyManager.CrunOptions;
var previousCheckUtc = settings.TrackedSeriesReleaseLastCheckUtc;
if (!historyRefreshNotificationsArmed){
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
CfgManager.WriteCrSettings();
return;
}
var trackedSeries = crunchyManager.HistoryList
.Where(series => !string.IsNullOrWhiteSpace(series.SeriesId))
.ToDictionary(series => series.SeriesId!, StringComparer.Ordinal);
if (trackedSeries.Count == 0){
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
CfgManager.WriteCrSettings();
return;
}
releaseFeedEpisodes ??= (await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang,
2000, null, true))?.Data ?? [];
var notificationSettings = settings.NotificationSettings;
var historyUpdated = false;
var windowStartUtc = previousCheckUtc?.Subtract(TrackedSeriesReleaseOverlap);
foreach (var release in releaseFeedEpisodes){
var seriesId = release.EpisodeMetadata?.SeriesId;
if (string.IsNullOrWhiteSpace(seriesId) || !trackedSeries.TryGetValue(seriesId, out var historySeries)){
continue;
}
var releaseDateUtc = GetTrackedReleaseDateUtc(release);
if (windowStartUtc.HasValue && releaseDateUtc < windowStartUtc.Value){
continue;
}
if (releaseDateUtc > currentCheckTimeUtc){
continue;
}
var historyEpisode = crunchyManager.History.GetHistoryEpisode(seriesId, release.EpisodeMetadata.SeasonId, release.Id ?? string.Empty);
if (historyEpisode == null && !string.IsNullOrWhiteSpace(release.Id)){
historyEpisode = historySeries.Seasons
.SelectMany(season => season.EpisodesList)
.FirstOrDefault(episode => episode.EpisodeId == release.Id);
}
if (historyEpisode == null || historyEpisode.TrackedSeriesReleaseNotified){
continue;
}
var notificationSent = await NotificationPublisher.Instance.PublishTrackedSeriesEpisodeReleasedAsync(
notificationSettings,
historySeries,
historyEpisode,
release,
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang
);
if (notificationSent){
historyEpisode.TrackedSeriesReleaseNotified = true;
historyUpdated = true;
}
}
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
if (historyUpdated){
CfgManager.UpdateHistoryFile();
}
CfgManager.WriteCrSettings();
}
private static DateTime GetTrackedReleaseDateUtc(CrBrowseEpisode episode){
DateTime episodeAirDate = episode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
: episode.EpisodeMetadata.EpisodeAirDate;
DateTime premiumAvailableStart = episode.EpisodeMetadata.PremiumAvailableDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime()
: episode.EpisodeMetadata.PremiumAvailableDate;
DateTime now = DateTime.Now;
DateTime oneYearFromNow = now.AddYears(1);
var targetDate = premiumAvailableStart;
if (targetDate >= oneYearFromNow){
DateTime freeAvailableStart = episode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
: episode.EpisodeMetadata.FreeAvailableDate;
if (freeAvailableStart <= oneYearFromNow){
targetDate = freeAvailableStart;
} else{
targetDate = episodeAirDate;
}
}
return targetDate.Kind == DateTimeKind.Utc ? targetDate : targetDate.ToUniversalTime();
} }
public void SetBackgroundImage(){ public void SetBackgroundImage(){
@ -224,6 +339,7 @@ public sealed partial class ProgramManager : ObservableObject{
CrunchyrollManager.Instance.InitOptions(); CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync(); UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
await Updater.Instance.CheckGhJsonAsync();
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){ if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor); if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);

View file

@ -5,26 +5,43 @@ using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.CustomList; using CRD.Utils.CustomList;
using CRD.Utils.QueueManagement;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.ViewModels; using CRD.ViewModels;
using CRD.Views;
using ReactiveUI;
namespace CRD.Downloader; namespace CRD.Downloader;
public sealed partial class QueueManager : ObservableObject{ public sealed partial class QueueManager : ObservableObject{
public static QueueManager Instance{ get; } = new(); public static QueueManager Instance{ get; } = new();
#region Download Variables #region Download Variables
public RefreshableObservableCollection<CrunchyEpMeta> Queue{ get; } = new(); private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = new();
public ObservableCollection<DownloadItemModel> DownloadItemModels{ get; } = new(); public ReadOnlyObservableCollection<CrunchyEpMeta> Queue{ get; }
private readonly DownloadItemModelCollection downloadItems = new();
public ObservableCollection<DownloadItemModel> DownloadItemModels => downloadItems.Items;
private readonly UiMutationQueue uiMutationQueue;
private readonly QueuePersistenceManager queuePersistenceManager;
private readonly object downloadStartLock = new();
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
private readonly ProcessingSlotManager processingSlots;
private int pumpScheduled;
private int pumpDirty;
private DateTimeOffset? autoDownloadBlockedUntilUtc;
private readonly object autoDownloadBlockLock = new();
#endregion
public int ActiveDownloads{ public int ActiveDownloads{
get{ get{
@ -34,18 +51,7 @@ public sealed partial class QueueManager : ObservableObject{
} }
} }
private readonly object downloadStartLock = new(); public bool HasActiveDownloads => ActiveDownloads > 0;
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
private readonly object processingLock = new();
private readonly SemaphoreSlim activeProcessingJobs;
private int processingJobsLimit;
private int borrowed;
private int pumpScheduled;
private int pumpDirty;
#endregion
[ObservableProperty] [ObservableProperty]
private bool hasFailedItem; private bool hasFailedItem;
@ -55,18 +61,43 @@ public sealed partial class QueueManager : ObservableObject{
private readonly CrunchyrollManager crunchyrollManager; private readonly CrunchyrollManager crunchyrollManager;
public QueueManager(){ public QueueManager(){
this.crunchyrollManager = CrunchyrollManager.Instance; crunchyrollManager = CrunchyrollManager.Instance;
activeProcessingJobs = new SemaphoreSlim( uiMutationQueue = new UiMutationQueue();
initialCount: crunchyrollManager.CrunOptions.SimultaneousProcessingJobs, queuePersistenceManager = new QueuePersistenceManager(this);
maxCount: 2); Queue = new ReadOnlyObservableCollection<CrunchyEpMeta>(queue);
processingJobsLimit = crunchyrollManager.CrunOptions.SimultaneousProcessingJobs; processingSlots = new ProcessingSlotManager(
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
Queue.CollectionChanged += UpdateItemListOnRemove; queue.CollectionChanged += UpdateItemListOnRemove;
Queue.CollectionChanged += (_, _) => OnQueueStateChanged(); queue.CollectionChanged += (_, _) => OnQueueStateChanged();
} }
public void AddToQueue(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => {
if (!queue.Contains(item))
queue.Add(item);
});
}
public void RemoveFromQueue(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => {
int index = queue.IndexOf(item);
if (index >= 0)
queue.RemoveAt(index);
});
}
public void ClearQueue(){
uiMutationQueue.Enqueue(() => queue.Clear());
}
public void RefreshQueue(){
uiMutationQueue.Enqueue(() => queue.Refresh());
}
public bool TryStartDownload(DownloadItemModel model){ public bool TryStartDownload(DownloadItemModel model){
var item = model.epMeta; var item = model.epMeta;
@ -74,13 +105,16 @@ public sealed partial class QueueManager : ObservableObject{
if (activeOrStarting.Contains(item)) if (activeOrStarting.Contains(item))
return false; return false;
if (item.DownloadProgress is{ IsDownloading: true }) if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
return false; return false;
if (item.DownloadProgress is{ Done: true }) if (item.DownloadProgress.IsDone)
return false; return false;
if (item.DownloadProgress is{ Error: true }) if (item.DownloadProgress.IsError)
return false;
if (item.DownloadProgress.IsPaused)
return false; return false;
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads) if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
@ -89,11 +123,31 @@ public sealed partial class QueueManager : ObservableObject{
activeOrStarting.Add(item); activeOrStarting.Add(item);
} }
NotifyDownloadStateChanged();
OnQueueStateChanged(); OnQueueStateChanged();
_ = model.StartDownloadCore(); _ = model.StartDownloadCore();
return true; return true;
} }
public bool TryResumeDownload(CrunchyEpMeta item){
lock (downloadStartLock){
if (activeOrStarting.Contains(item))
return false;
if (!item.DownloadProgress.IsPaused)
return false;
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
return false;
activeOrStarting.Add(item);
}
NotifyDownloadStateChanged();
OnQueueStateChanged();
return true;
}
public void ReleaseDownloadSlot(CrunchyEpMeta item){ public void ReleaseDownloadSlot(CrunchyEpMeta item){
bool removed; bool removed;
@ -102,110 +156,98 @@ public sealed partial class QueueManager : ObservableObject{
} }
if (removed){ if (removed){
NotifyDownloadStateChanged();
OnQueueStateChanged(); OnQueueStateChanged();
RequestPump();
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
} }
} }
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){ public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
return activeProcessingJobs.WaitAsync(cancellationToken); return processingSlots.WaitAsync(cancellationToken);
} }
public void ReleaseProcessingSlot(){ public void ReleaseProcessingSlot(){
lock (processingLock){ processingSlots.Release();
if (borrowed > 0){
borrowed--;
return;
}
activeProcessingJobs.Release();
}
} }
public void SetLimit(int newLimit){ public void SetProcessingLimit(int newLimit){
if (newLimit < 0) processingSlots.SetLimit(newLimit);
throw new ArgumentOutOfRangeException(nameof(newLimit)); }
lock (processingLock){ public void RestorePersistedQueue(){
if (newLimit == processingJobsLimit) queuePersistenceManager.RestoreQueue();
return; }
int delta = newLimit - processingJobsLimit; public void SaveQueueSnapshot(){
queuePersistenceManager.SaveNow();
}
if (delta > 0){ internal List<CrunchyEpMeta> GetQueueSnapshot(){
int giveBack = Math.Min(borrowed, delta); if (Dispatcher.UIThread.CheckAccess()){
borrowed -= giveBack; return queue.ToList();
}
int toRelease = delta - giveBack; return Dispatcher.UIThread
if (toRelease > 0) .InvokeAsync(() => queue.ToList())
activeProcessingJobs.Release(toRelease); .GetAwaiter()
} else{ .GetResult();
int toRemove = -delta; }
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> items){
while (toRemove > 0 && activeProcessingJobs.Wait(0)){ uiMutationQueue.Enqueue(() => {
toRemove--; queue.Clear();
} foreach (var item in items){
if (!queue.Contains(item))
queue.Add(item);
borrowed += toRemove;
} }
processingJobsLimit = newLimit; RestoreRetryStateFromQueue();
} UpdateDownloadListItems();
});
} }
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){ private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
if (e.Action == NotifyCollectionChangedAction.Remove){ if (e.Action == NotifyCollectionChangedAction.Remove){
if (e.OldItems != null) if (e.OldItems != null){
foreach (var eOldItem in e.OldItems){ foreach (var oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
var downloadItem = DownloadItemModels.FirstOrDefault(downloadItem => downloadItem.epMeta.Equals(eOldItem)); downloadItems.Remove(oldItem);
if (downloadItem != null){
DownloadItemModels.Remove(downloadItem);
} else{
Console.Error.WriteLine("Failed to Remove Episode from list");
}
} }
} else if (e.Action == NotifyCollectionChangedAction.Reset && Queue.Count == 0){ }
DownloadItemModels.Clear(); } else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
downloadItems.Clear();
} }
UpdateDownloadListItems(); UpdateDownloadListItems();
} }
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){ public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
Avalonia.Threading.Dispatcher.UIThread.Post(() => { uiMutationQueue.Enqueue(() => {
if (removeFromQueue){ if (removeFromQueue){
if (Queue.Contains(item)) int index = queue.IndexOf(item);
Queue.Remove(item); if (index >= 0)
queue.RemoveAt(index);
} else{ } else{
Queue.Refresh(); queue.Refresh();
} }
OnQueueStateChanged(); OnQueueStateChanged();
}, Avalonia.Threading.DispatcherPriority.Background); });
} }
public void UpdateDownloadListItems(){ public void UpdateDownloadListItems(){
foreach (CrunchyEpMeta crunchyEpMeta in Queue.ToList()){ downloadItems.SyncFromQueue(queue);
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
if (downloadItem != null){
downloadItem.Refresh();
} else{
downloadItem = new DownloadItemModel(crunchyEpMeta);
_ = downloadItem.LoadImage();
DownloadItemModels.Add(downloadItem);
}
}
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error); HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
if (crunchyrollManager.CrunOptions.AutoDownload){ if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump(); RequestPump();
} }
} }
public void RequestPump(){ private void RequestPump(){
Interlocked.Exchange(ref pumpDirty, 1); Interlocked.Exchange(ref pumpDirty, 1);
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0) if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
@ -234,7 +276,26 @@ public sealed partial class QueueManager : ObservableObject{
} }
private void PumpQueue(){ private void PumpQueue(){
if (!crunchyrollManager.CrunOptions.AutoDownload)
return;
lock (autoDownloadBlockLock){
if (autoDownloadBlockedUntilUtc.HasValue && !HasPendingRetryItems()){
autoDownloadBlockedUntilUtc = null;
}
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value > DateTimeOffset.UtcNow){
return;
}
if (autoDownloadBlockedUntilUtc.HasValue){
autoDownloadBlockedUntilUtc = null;
}
}
List<CrunchyEpMeta> toStart = new(); List<CrunchyEpMeta> toStart = new();
List<CrunchyEpMeta> toResume = new();
bool changed = false;
lock (downloadStartLock){ lock (downloadStartLock){
int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads; int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads;
@ -243,17 +304,20 @@ public sealed partial class QueueManager : ObservableObject{
if (freeSlots == 0) if (freeSlots == 0)
return; return;
foreach (var item in Queue.ToList()){ foreach (var item in queue.ToList()){
if (freeSlots == 0) if (freeSlots == 0)
break; break;
if (item.DownloadProgress.Error) if (item.DownloadProgress.IsError)
continue; continue;
if (item.DownloadProgress.Done) if (item.DownloadProgress.IsWaitingForRetry)
continue; continue;
if (item.DownloadProgress.IsDownloading) if (item.DownloadProgress.IsDone)
continue;
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
continue; continue;
if (activeOrStarting.Contains(item)) if (activeOrStarting.Contains(item))
@ -261,12 +325,29 @@ public sealed partial class QueueManager : ObservableObject{
activeOrStarting.Add(item); activeOrStarting.Add(item);
freeSlots--; freeSlots--;
toStart.Add(item);
if (item.DownloadProgress.IsPaused){
toResume.Add(item);
} else{
toStart.Add(item);
}
changed = true;
} }
} }
if (changed){
NotifyDownloadStateChanged();
}
foreach (var item in toResume){
item.DownloadProgress.State = item.DownloadProgress.ResumeState;
var model = downloadItems.Find(item);
model?.Refresh();
}
foreach (var item in toStart){ foreach (var item in toStart){
var model = DownloadItemModels.FirstOrDefault(x => x.epMeta.Equals(item)); var model = downloadItems.Find(item);
if (model != null){ if (model != null){
_ = model.StartDownloadCore(); _ = model.StartDownloadCore();
} else{ } else{
@ -277,431 +358,110 @@ public sealed partial class QueueManager : ObservableObject{
OnQueueStateChanged(); OnQueueStateChanged();
} }
public void BlockAutoDownloadUntil(TimeSpan delay, CancellationToken cancellationToken = default){
DateTimeOffset unblockAt = DateTimeOffset.UtcNow.Add(delay);
lock (autoDownloadBlockLock){
if (!autoDownloadBlockedUntilUtc.HasValue || unblockAt > autoDownloadBlockedUntilUtc.Value){
autoDownloadBlockedUntilUtc = unblockAt;
} else{
unblockAt = autoDownloadBlockedUntilUtc.Value;
}
}
_ = Task.Run(async () => {
try{
var remaining = unblockAt - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero){
await Task.Delay(remaining, cancellationToken);
}
lock (autoDownloadBlockLock){
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value <= DateTimeOffset.UtcNow){
autoDownloadBlockedUntilUtc = null;
}
}
RefreshQueue();
UpdateDownloadListItems();
} catch (OperationCanceledException){
// ignored
}
}, cancellationToken);
}
public void ScheduleRetry(CrunchyEpMeta item, TimeSpan delay, string statusText, CancellationToken cancellationToken = default){
item.DownloadProgress.ScheduleRetry(delay, statusText);
RefreshQueue();
OnQueueStateChanged();
ScheduleRetryWake(item, item.DownloadProgress.RetryAtUtc, cancellationToken);
}
private void RestoreRetryStateFromQueue(){
var retryItems = queue
.Where(item => item.DownloadProgress.IsWaitingForRetry)
.ToList();
if (retryItems.Count == 0){
lock (autoDownloadBlockLock){
autoDownloadBlockedUntilUtc = null;
}
return;
}
var maxRetryAt = retryItems
.Select(item => item.DownloadProgress.RetryAtUtc)
.OfType<DateTimeOffset>()
.Max();
lock (autoDownloadBlockLock){
autoDownloadBlockedUntilUtc = maxRetryAt;
}
foreach (var retryItem in retryItems){
ScheduleRetryWake(retryItem, retryItem.DownloadProgress.RetryAtUtc);
}
}
private bool HasPendingRetryItems(){
return queue.Any(item => item.DownloadProgress.IsWaitingForRetry);
}
private void ScheduleRetryWake(CrunchyEpMeta item, DateTimeOffset? retryAtUtc, CancellationToken cancellationToken = default){
if (!retryAtUtc.HasValue){
return;
}
_ = Task.Run(async () => {
try{
var remaining = retryAtUtc.Value - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero){
await Task.Delay(remaining, cancellationToken);
}
if (cancellationToken.IsCancellationRequested){
return;
}
item.DownloadProgress.RetryAtUtc = null;
RefreshQueue();
UpdateDownloadListItems();
} catch (OperationCanceledException){
// ignored
}
}, cancellationToken);
}
private void OnQueueStateChanged(){ private void OnQueueStateChanged(){
QueueStateChanged?.Invoke(this, EventArgs.Empty); QueueStateChanged?.Invoke(this, EventArgs.Empty);
} }
private void NotifyDownloadStateChanged(){
public async Task CrAddEpisodeToQueue(string epId, string crLocale, List<string> dubLang, bool updateHistory = false, EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default){ OnPropertyChanged(nameof(ActiveDownloads));
if (string.IsNullOrEmpty(epId)){ OnPropertyChanged(nameof(HasActiveDownloads));
return;
}
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var episodeL = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(epId, crLocale);
if (episodeL != null){
if (episodeL.IsPremiumOnly && !CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.HasPremium){
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
return;
}
var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episodeL, updateHistory);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist;
}
}
var selected = CrunchyrollManager.Instance.CrEpisode.EpisodeMeta(sList, dubLang);
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
if (selected.Data is{ Count: > 0 }){
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(selected.Data.First().MediaId,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
selected.Description = episode?.Description ?? selected.Description;
}
}
if (selected.Data is{ Count: > 0 }){
if (CrunchyrollManager.Instance.CrunOptions.History){
// var historyEpisode = CrHistory.GetHistoryEpisodeWithDownloadDir(selected.ShowId, selected.SeasonId, selected.Data.First().MediaId);
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
if (historyEpisode.historyEpisode != null){
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
selected.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
}
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
selected.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
}
}
}
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
selected.DownloadPath = historyEpisode.downloadDirPath;
}
}
selected.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
selected.DownloadSubs = historyEpisode.sublist.Count > 0 ? historyEpisode.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
selected.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && selected.Data.Count > 1){
var sortedMetaData = selected.Data
.OrderBy(metaData => {
var locale = metaData.Lang?.CrLocale ?? string.Empty;
var index = dubLang.IndexOf(locale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
if (sortedMetaData.Count != 0){
var first = sortedMetaData.First();
selected.Data = [first];
selected.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
}
}
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false;
newOptions.Noaudio = true;
selected.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true;
newOptions.Noaudio = false;
selected.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true;
newOptions.Noaudio = true;
break;
case EpisodeDownloadMode.Default:
default:
break;
}
if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
selected.HighlightAllAvailable = true;
}
}
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!selected.DownloadSubs.Contains("none") && !selected.DownloadSubs.Contains("all") && !selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
return;
}
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
return;
}
}
newOptions.DubLang = dubLang;
selected.DownloadSettings = newOptions;
Queue.Add(selected);
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
.ToArray();
Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
} else{
Console.WriteLine("Added Episode to Queue");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
}
} else{
Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
.ToArray();
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
if (!CrunchyrollManager.Instance.CrunOptions.DownloadOnlyWithAllSelectedDubSub){
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
}
}
return;
}
Console.WriteLine("Couldn't find episode trying to find movie with id");
var movie = await CrunchyrollManager.Instance.CrMovies.ParseMovieById(epId, crLocale);
if (movie != null){
var movieMeta = CrunchyrollManager.Instance.CrMovies.EpisodeMeta(movie, dubLang);
if (movieMeta != null){
movieMeta.DownloadSubs = CrunchyrollManager.Instance.CrunOptions.DlSubs;
movieMeta.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false;
newOptions.Noaudio = true;
movieMeta.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true;
newOptions.Noaudio = false;
movieMeta.DownloadSubs = ["none"];
break;
case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true;
newOptions.Noaudio = true;
break;
case EpisodeDownloadMode.Default:
default:
break;
}
newOptions.DubLang = dubLang;
movieMeta.DownloadSettings = newOptions;
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!movieMeta.DownloadSubs.Contains("none") && !movieMeta.DownloadSubs.Contains("all") && !movieMeta.DownloadSubs.All(item => (movieMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
if (movieMeta.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
}
Queue.Add(movieMeta);
Console.WriteLine("Added Movie to Queue");
MessageBus.Current.SendMessage(new ToastMessage($"Added Movie to Queue", ToastType.Information, 1));
return;
}
}
Console.Error.WriteLine($"No episode or movie found with the id: {epId}");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue - No episode or movie found with the id: {epId}", ToastType.Error, 3));
} }
public void CrAddMusicMetaToQueue(CrunchyEpMeta epMeta){
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
epMeta.DownloadSettings = newOptions;
Queue.Add(epMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
}
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
if (musicVideo != null){
var musicVideoMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(musicVideo);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
}
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
musicVideoMeta.DownloadSettings = newOptions;
Queue.Add(musicVideoMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added music video to the queue", ToastType.Information, 1));
}
}
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
if (concert != null){
var concertMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(concert);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
}
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
concertMeta.DownloadSettings = newOptions;
Queue.Add(concertMeta);
MessageBus.Current.SendMessage(new ToastMessage($"Added concert to the queue", ToastType.Information, 1));
}
}
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var failed = false;
var partialAdd = false;
foreach (var crunchyEpMeta in selected.Values.ToList()){
if (crunchyEpMeta.Data.FirstOrDefault() != null){
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDownloadDir(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId, crunchyEpMeta.Data.First().MediaId);
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
if (historyEpisode.historyEpisode != null){
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
crunchyEpMeta.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
}
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
crunchyEpMeta.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
}
}
}
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
crunchyEpMeta.DownloadPath = historyEpisode.downloadDirPath;
}
}
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
if (crunchyEpMeta.Data is{ Count: > 0 }){
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(crunchyEpMeta.Data.First().MediaId,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
crunchyEpMeta.Description = episode?.Description ?? crunchyEpMeta.Description;
}
}
var subLangList = CrunchyrollManager.Instance.History.GetSubList(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId);
crunchyEpMeta.VideoQuality = !string.IsNullOrEmpty(subLangList.videoQuality) ? subLangList.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
crunchyEpMeta.DownloadSubs = subLangList.sublist.Count > 0 ? subLangList.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && crunchyEpMeta.Data.Count > 1){
var sortedMetaData = crunchyEpMeta.Data
.OrderBy(metaData => {
var locale = metaData.Lang?.CrLocale ?? string.Empty;
var index = data.DubLang.IndexOf(locale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
if (sortedMetaData.Count != 0){
var first = sortedMetaData.First();
crunchyEpMeta.Data = [first];
crunchyEpMeta.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
}
}
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (newOptions == null){
Console.Error.WriteLine("Failed to create a copy of your current settings");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
return;
}
if (crunchyEpMeta.OnlySubs){
newOptions.Novids = true;
newOptions.Noaudio = true;
}
newOptions.DubLang = data.DubLang;
crunchyEpMeta.DownloadSettings = newOptions;
if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
crunchyEpMeta.HighlightAllAvailable = true;
}
}
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!crunchyEpMeta.DownloadSubs.Contains("none") && !crunchyEpMeta.DownloadSubs.Contains("all") && !crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
}
Queue.Add(crunchyEpMeta);
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
partialAdd = true;
var languages = (crunchyEpMeta.Data.First().Versions ?? []).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray();
Console.Error.WriteLine(
$"{crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", crunchyEpMeta.AvailableSubs ?? [])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
}
} else{
failed = true;
}
}
if (failed && !partialAdd){
MainWindow.Instance.ShowError("Not all episodes could be added make sure that you are signed in with an account that has an active premium subscription?");
} else if (selected.Values.Count > 0 && !partialAdd){
MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1));
} else if (!partialAdd){
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2));
}
}
} }

View file

@ -1,29 +1,178 @@
using System; using System;
using System.IO;
using System.Threading.Tasks;
using NetCoreAudio; using NetCoreAudio;
using NAudio.Wave;
namespace CRD.Utils; namespace CRD.Utils;
public class AudioPlayer{ public class AudioPlayer{
private readonly Player _player; private readonly Player _player;
private bool _isPlaying = false; private bool _isPlaying;
private WaveOutEvent? _waveOut;
private AudioFileReader? _audioFileReader;
private TaskCompletionSource? _playbackCompleted;
public AudioPlayer(){ public AudioPlayer(){
_player = new Player(); _player = new Player();
} }
public async void Play(string path){ public static (bool IsValid, string ErrorMessage) ValidateSoundFile(string path){
if (string.IsNullOrWhiteSpace(path)){
return (false, "The selected sound file path is empty.");
}
if (!File.Exists(path)){
return (false, "The selected sound file does not exist.");
}
try{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
if (stream.Length == 0){
return (false, "The selected sound file is empty.");
}
} catch (Exception exception){
return (false, $"The selected sound file could not be opened: {exception.Message}");
}
if (string.IsNullOrWhiteSpace(Path.GetExtension(path))){
return (false, "The selected sound file has no file extension.");
}
return (true, string.Empty);
}
public async Task<(bool IsSuccess, string ErrorMessage)> ValidatePlaybackAsync(string path){
var fileValidation = ValidateSoundFile(path);
if (!fileValidation.IsValid){
return (false, fileValidation.ErrorMessage);
}
if (_isPlaying){
return (false, "Audio playback is already in progress.");
}
if (OperatingSystem.IsWindows()){
try{
_isPlaying = true;
DisposeWindowsPlayback();
_audioFileReader = new AudioFileReader(path);
_waveOut = new WaveOutEvent();
_playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_waveOut.PlaybackStopped += (_, args) => {
if (args.Exception != null){
_playbackCompleted?.TrySetException(args.Exception);
} else{
_playbackCompleted?.TrySetResult();
}
};
_waveOut.Init(_audioFileReader);
_waveOut.Play();
await _playbackCompleted.Task;
return (true, string.Empty);
} catch (Exception exception){
return (false, exception.Message);
} finally{
DisposeWindowsPlayback();
_isPlaying = false;
}
}
try{
_isPlaying = true;
await _player.Play(path);
return (true, string.Empty);
} catch (Exception exception){
return (false, exception.Message);
} finally{
_isPlaying = false;
}
}
public async Task PlayAsync(string path){
var fileValidation = ValidateSoundFile(path);
if (!fileValidation.IsValid){
Console.Error.WriteLine($"Failed to play audio '{path}': {fileValidation.ErrorMessage}");
return;
}
if (_isPlaying){ if (_isPlaying){
Console.WriteLine("Audio is already playing, ignoring duplicate request."); Console.WriteLine("Audio is already playing, ignoring duplicate request.");
return; return;
} }
_isPlaying = true; if (OperatingSystem.IsWindows()){
await _player.Play(path); try{
_isPlaying = false; _isPlaying = true;
DisposeWindowsPlayback();
_audioFileReader = new AudioFileReader(path);
_waveOut = new WaveOutEvent();
_playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_waveOut.PlaybackStopped += (_, args) => {
if (args.Exception != null){
_playbackCompleted?.TrySetException(args.Exception);
} else{
_playbackCompleted?.TrySetResult();
}
};
_waveOut.Init(_audioFileReader);
_waveOut.Play();
await _playbackCompleted.Task;
} catch (Exception exception){
Console.Error.WriteLine($"Failed to play audio '{path}': {exception}");
} finally{
DisposeWindowsPlayback();
_isPlaying = false;
}
return;
}
try{
_isPlaying = true;
await _player.Play(path);
} catch (Exception exception){
Console.Error.WriteLine($"Failed to play audio '{path}': {exception.Message}");
} finally{
_isPlaying = false;
}
} }
public async void Stop(){ public async Task StopAsync(){
await _player.Stop(); if (OperatingSystem.IsWindows()){
_isPlaying = false; try{
_waveOut?.Stop();
} catch (Exception exception){
Console.Error.WriteLine($"Failed to stop audio playback: {exception}");
} finally{
DisposeWindowsPlayback();
_isPlaying = false;
}
return;
}
try{
await _player.Stop();
} catch (Exception exception){
Console.Error.WriteLine($"Failed to stop audio playback: {exception}");
} finally{
_isPlaying = false;
}
}
private void DisposeWindowsPlayback(){
_playbackCompleted = null;
_waveOut?.Dispose();
_waveOut = null;
_audioFileReader?.Dispose();
_audioFileReader = null;
} }
} }

View file

@ -13,7 +13,7 @@ public class Widevine{
private byte[] privateKey = new byte[0]; private byte[] privateKey = new byte[0];
private byte[] identifierBlob = new byte[0]; private byte[] identifierBlob = new byte[0];
public bool canDecrypt = false; public bool canDecrypt;
#region Singelton #region Singelton
@ -114,7 +114,7 @@ public class Widevine{
// var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2); // var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
var response = (IsOk: false, ResponseContent: "", error: ""); var response = (IsOk: false, ResponseContent: "", error: "",Headers: new Dictionary<string, string>());
for (var attempt = 0; attempt < 3 + 1; attempt++){ for (var attempt = 0; attempt < 3 + 1; attempt++){
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){ using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
response = await HttpClientReq.Instance.SendHttpRequest(request); response = await HttpClientReq.Instance.SendHttpRequest(request);

View file

@ -44,6 +44,8 @@ public enum SeriesType{
Artist, Artist,
[EnumMember(Value = "Series")] [EnumMember(Value = "Series")]
Series, Series,
[EnumMember(Value = "Movie")]
Movie,
[EnumMember(Value = "Unknown")] [EnumMember(Value = "Unknown")]
Unknown Unknown
} }
@ -276,6 +278,15 @@ public enum EpisodeDownloadMode{
OnlySubs, OnlySubs,
} }
public enum DownloadState{
Queued,
Downloading,
Paused,
Processing,
Done,
Error
}
public enum HistoryRefreshMode{ public enum HistoryRefreshMode{
DefaultAll = 0, DefaultAll = 0,
DefaultActive = 1, DefaultActive = 1,

View file

@ -21,6 +21,7 @@ public class CfgManager{
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json"); public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json"); public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json");
public static readonly string PathCrQueue = Path.Combine(workingDirectory, "config", "queue.json");
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json"); public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -44,7 +45,7 @@ public class CfgManager{
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt"); public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
private static StreamWriter logFile; private static StreamWriter logFile;
private static bool isLogModeEnabled = false; private static bool isLogModeEnabled;
static CfgManager(){ static CfgManager(){
AppDomain.CurrentDomain.ProcessExit += OnProcessExit; AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
@ -366,4 +367,14 @@ public class CfgManager{
return null; return null;
} }
} }
public static void DeleteFileIfExists(string pathToFile){
try{
if (File.Exists(pathToFile)){
File.Delete(pathToFile);
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
}
}
} }

View file

@ -17,6 +17,7 @@ using Newtonsoft.Json;
namespace CRD.Utils.HLS; namespace CRD.Utils.HLS;
public class HlsDownloader{ public class HlsDownloader{
private readonly CancellationToken _cancellationToken;
private Data _data = new(); private Data _data = new();
private CrunchyEpMeta _currentEpMeta; private CrunchyEpMeta _currentEpMeta;
@ -24,12 +25,24 @@ public class HlsDownloader{
private bool _isAudio; private bool _isAudio;
private bool _newDownloadMethode; private bool _newDownloadMethode;
private async Task WaitWhilePausedAsync(CancellationToken cancellationToken){
while (_currentEpMeta.DownloadProgress.IsPaused){
cancellationToken.ThrowIfCancellationRequested();
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
throw new OperationCanceledException(cancellationToken);
}
await Task.Delay(500, cancellationToken);
}
}
public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio, bool newDownloadMethode){ public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio, bool newDownloadMethode){
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){ if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
throw new Exception("Playlist is empty"); throw new Exception("Playlist is empty");
} }
_currentEpMeta = meta; _currentEpMeta = meta;
_cancellationToken = meta.Cts.Token;
_isVideo = isVideo; _isVideo = isVideo;
_isAudio = isAudio; _isAudio = isAudio;
@ -62,6 +75,7 @@ public class HlsDownloader{
public async Task<(bool Ok, PartsData Parts)> Download(){ public async Task<(bool Ok, PartsData Parts)> Download(){
_cancellationToken.ThrowIfCancellationRequested();
string fn = _data.OutputFile ?? string.Empty; string fn = _data.OutputFile ?? string.Empty;
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){ if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
@ -141,8 +155,8 @@ public class HlsDownloader{
try{ try{
var initDl = await DownloadPart(initSeg, 0, 0); var initDl = await DownloadPart(initSeg, 0, 0);
await File.WriteAllBytesAsync(fn, initDl); await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count })); await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }), _cancellationToken);
Console.WriteLine("Init part downloaded."); Console.WriteLine("Init part downloaded.");
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}"); Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
@ -185,7 +199,7 @@ public class HlsDownloader{
} }
try{ try{
await Task.WhenAll(keyTasks.Values); await Task.WhenAll(keyTasks.Values).WaitAsync(_cancellationToken);
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"Error downloading keys: {ex.Message}"); Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
throw; throw;
@ -200,7 +214,7 @@ public class HlsDownloader{
} }
while (partTasks.Count > 0){ while (partTasks.Count > 0){
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values); Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values).WaitAsync(_cancellationToken);
int completedIndex = -1; int completedIndex = -1;
foreach (var task in partTasks){ foreach (var task in partTasks){
if (task.Value == completedTask){ if (task.Value == completedTask){
@ -234,7 +248,7 @@ public class HlsDownloader{
while (attempt < 3 && !writeSuccess){ while (attempt < 3 && !writeSuccess){
try{ try{
using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){ using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
await stream.WriteAsync(part, 0, part.Length); await stream.WriteAsync(part, 0, part.Length, _cancellationToken);
} }
writeSuccess = true; writeSuccess = true;
@ -242,7 +256,7 @@ public class HlsDownloader{
Console.Error.WriteLine(ex); Console.Error.WriteLine(ex);
Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)"); Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)");
Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying"); Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying");
await Task.Delay(_data.WaitTime); await Task.Delay(_data.WaitTime, _cancellationToken);
attempt++; attempt++;
} }
} }
@ -268,11 +282,16 @@ public class HlsDownloader{
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" ? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s"; : $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
await WaitWhilePausedAsync(_cancellationToken);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
return (Ok: false, _data.Parts);
}
// Log progress // Log progress
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})"); Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})");
_currentEpMeta.DownloadProgress = new DownloadProgress(){ _currentEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true, State = DownloadState.Downloading,
Percent = dataLog.Percent, Percent = dataLog.Percent,
Time = dataLog.Time, Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes, DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
@ -280,7 +299,7 @@ public class HlsDownloader{
}; };
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){ if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){ if (!_currentEpMeta.DownloadProgress.IsDone){
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){ foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
try{ try{
if (File.Exists(downloadItemDownloadedFile)){ if (File.Exists(downloadItemDownloadedFile)){
@ -295,13 +314,11 @@ public class HlsDownloader{
return (Ok: false, _data.Parts); return (Ok: false, _data.Parts);
} }
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
while (_currentEpMeta.Paused){ await WaitWhilePausedAsync(_cancellationToken);
await Task.Delay(500); if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){ return (Ok: false, _data.Parts);
return (Ok: false, _data.Parts);
}
} }
} }
} }
@ -311,10 +328,110 @@ public class HlsDownloader{
private static readonly object _resumeLock = new object(); private static readonly object _resumeLock = new object();
private void CleanupDownloadedFiles(){
if (_currentEpMeta.DownloadProgress.IsDone){
return;
}
foreach (var file in _currentEpMeta.downloadedFiles){
try{
if (File.Exists(file)){
File.Delete(file);
}
} catch (Exception ex){
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
}
}
}
private async Task DownloadBufferedSegmentAsync(int index, List<dynamic> segments, string tempDir, string resumeFile, int totalSeg, int mergedParts, SemaphoreSlim semaphore,
CancellationTokenSource cancellationSource, CancellationToken token, Action markError, Func<long> getLastUiUpdate, Action<long> setLastUiUpdate){
try{
token.ThrowIfCancellationRequested();
await WaitWhilePausedAsync(token);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cancellationSource.Cancel();
return;
}
var segment = new Segment{
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
};
var data = await DownloadPart(segment, index, _data.Offset);
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
await File.WriteAllBytesAsync(tempFile, data, token);
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
lock (_resumeLock){
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
DownloadedParts = currentDownloaded,
MergedParts = mergedParts,
Total = totalSeg
}));
}
long lastUiUpdate = getLastUiUpdate();
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - lastUiUpdate > 500){
var dataLog = GetDownloadInfo(
lastUiUpdate,
currentDownloaded,
totalSeg,
_data.BytesDownloaded,
_data.TotalBytes
);
_data.BytesDownloaded = 0;
setLastUiUpdate(DateTimeOffset.Now.ToUnixTimeMilliseconds());
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
await WaitWhilePausedAsync(token);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cancellationSource.Cancel();
return;
}
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
_currentEpMeta.DownloadProgress = new DownloadProgress{
State = DownloadState.Downloading,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
};
}
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cancellationSource.Cancel();
return;
}
QueueManager.Instance.RefreshQueue();
await WaitWhilePausedAsync(token);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cancellationSource.Cancel();
}
} catch (Exception ex){
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
markError();
cancellationSource.Cancel();
} finally{
semaphore.Release();
}
}
public async Task<(bool Ok, PartsData Parts)> DownloadSegmentsBufferedResumeAsync(List<dynamic> segments, string fn){ public async Task<(bool Ok, PartsData Parts)> DownloadSegmentsBufferedResumeAsync(List<dynamic> segments, string fn){
var totalSeg = _data.Parts.Total; var totalSeg = _data.Parts.Total;
string sessionId = Path.GetFileNameWithoutExtension(fn); string sessionId = Path.GetFileNameWithoutExtension(fn);
string tempDir = Path.Combine(Path.GetDirectoryName(fn), $"{sessionId}_temp"); string tempDir = Path.Combine(Path.GetDirectoryName(fn) ?? string.Empty, $"{sessionId}_temp");
Directory.CreateDirectory(tempDir); Directory.CreateDirectory(tempDir);
@ -328,6 +445,7 @@ public class HlsDownloader{
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0; downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
mergedParts = (int?)resumeData?.MergedParts ?? 0; mergedParts = (int?)resumeData?.MergedParts ?? 0;
} catch{ } catch{
// ignored
} }
} }
@ -336,133 +454,76 @@ public class HlsDownloader{
var semaphore = new SemaphoreSlim(_data.Threads); var semaphore = new SemaphoreSlim(_data.Threads);
var downloadTasks = new List<Task>(); var downloadTasks = new List<Task>();
bool errorOccurred = false; int errorOccurred = 0;
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds(); var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var cts = new CancellationTokenSource(); var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
var token = cts.Token; var token = cts.Token;
for (int i = 0; i < segments.Count; i++){ void CleanupBufferedArtifacts(bool cleanAll = true){
try{ CleanupNewDownloadMethod(tempDir, resumeFile, cleanAll);
await semaphore.WaitAsync(token);
} catch (OperationCanceledException){
break;
}
if (File.Exists(Path.Combine(tempDir, $"part_{i:D6}.tmp"))){
semaphore.Release();
continue;
}
int index = i;
downloadTasks.Add(Task.Run(async () => {
try{
token.ThrowIfCancellationRequested();
var segment = new Segment{
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
};
var data = await DownloadPart(segment, index, _data.Offset);
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
await File.WriteAllBytesAsync(tempFile, data);
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
lock (_resumeLock){
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
DownloadedParts = currentDownloaded,
MergedParts = mergedParts,
Total = totalSeg
}));
}
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - _lastUiUpdate > 500){
var dataLog = GetDownloadInfo(
_lastUiUpdate,
currentDownloaded,
totalSeg,
_data.BytesDownloaded,
_data.TotalBytes
);
_data.BytesDownloaded = 0;
_lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
_currentEpMeta.DownloadProgress = new DownloadProgress{
IsDownloading = true,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
};
}
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cts.Cancel();
return;
}
QueueManager.Instance.Queue.Refresh();
while (_currentEpMeta.Paused){
await Task.Delay(500);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cts.Cancel();
return;
}
}
} catch (Exception ex){
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
errorOccurred = true;
cts.Cancel();
} finally{
semaphore.Release();
}
}, token));
} }
try{ try{
await Task.WhenAll(downloadTasks); for (int i = 0; i < segments.Count; i++){
} catch (OperationCanceledException){ try{
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){ await semaphore.WaitAsync(token);
if (!_currentEpMeta.DownloadProgress.Done){ } catch (OperationCanceledException){
CleanupNewDownloadMethod(tempDir, resumeFile, true); break;
} }
} else{
Console.Error.WriteLine("Download cancelled due to error."); if (File.Exists(Path.Combine(tempDir, $"part_{i:D6}.tmp"))){
semaphore.Release();
continue;
}
int index = i;
downloadTasks.Add(DownloadBufferedSegmentAsync(index, segments, tempDir, resumeFile, totalSeg, mergedParts, semaphore, cts, token,
() => Interlocked.Exchange(ref errorOccurred, 1),
() => Volatile.Read(ref _lastUiUpdate),
value => Interlocked.Exchange(ref _lastUiUpdate, value)));
} }
try{
await Task.WhenAll(downloadTasks);
} catch (OperationCanceledException){
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
CleanupBufferedArtifacts();
} else{
Console.Error.WriteLine("Download cancelled due to error.");
CleanupBufferedArtifacts(false);
}
return (false, _data.Parts);
}
} finally{
cts.Dispose();
}
if (Volatile.Read(ref errorOccurred) == 1){
CleanupBufferedArtifacts(false);
return (false, _data.Parts); return (false, _data.Parts);
} }
if (errorOccurred)
return (false, _data.Parts);
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){ using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
for (int i = mergedParts; i < segments.Count; i++){ for (int i = mergedParts; i < segments.Count; i++){
if (token.IsCancellationRequested) if (token.IsCancellationRequested){
CleanupBufferedArtifacts();
return (false, _data.Parts); return (false, _data.Parts);
}
string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp"); string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp");
if (!File.Exists(tempFile)){ if (!File.Exists(tempFile)){
Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge."); Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge.");
CleanupBufferedArtifacts(false);
return (false, _data.Parts); return (false, _data.Parts);
} }
byte[] data = await File.ReadAllBytesAsync(tempFile); byte[] data = await File.ReadAllBytesAsync(tempFile, token);
await output.WriteAsync(data, 0, data.Length); await output.WriteAsync(data, 0, data.Length, token);
mergedParts++; mergedParts++;
@ -475,21 +536,24 @@ public class HlsDownloader{
var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes); var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes);
Console.WriteLine($"{mergedParts}/{totalSeg} parts merged [{dataLog.Percent}%]"); Console.WriteLine($"{mergedParts}/{totalSeg} parts merged [{dataLog.Percent}%]");
await WaitWhilePausedAsync(token);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
CleanupBufferedArtifacts();
return (false, _data.Parts);
}
_currentEpMeta.DownloadProgress = new DownloadProgress{ _currentEpMeta.DownloadProgress = new DownloadProgress{
IsDownloading = true, State = DownloadState.Processing,
Percent = dataLog.Percent, Percent = dataLog.Percent,
Time = dataLog.Time, Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes, DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "") Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.RefreshQueue();
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){ if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){ CleanupBufferedArtifacts();
CleanupNewDownloadMethod(tempDir, resumeFile, true);
}
return (false, _data.Parts); return (false, _data.Parts);
} }
} }
@ -503,14 +567,7 @@ public class HlsDownloader{
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){ private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
if (cleanAll){ if (cleanAll){
// Delete downloaded files CleanupDownloadedFiles();
foreach (var file in _currentEpMeta.downloadedFiles){
try{
File.Delete(file); // Safe: File.Delete does nothing if file doesn't exist
} catch (Exception ex){
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
}
}
} }
// Delete temp directory // Delete temp directory
@ -565,6 +622,7 @@ public class HlsDownloader{
} }
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){ public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
_cancellationToken.ThrowIfCancellationRequested();
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl); string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
byte[]? dec = null; byte[]? dec = null;
int p = segIndex; int p = segIndex;
@ -572,7 +630,7 @@ public class HlsDownloader{
byte[]? part; byte[]? part;
if (seg.Key != null){ if (seg.Key != null){
var decipher = await GetKey(seg.Key, p, segOffset); var decipher = await GetKey(seg.Key, p, segOffset);
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries); part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
var partContent = part; var partContent = part;
using (decipher){ using (decipher){
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length); if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
@ -583,7 +641,7 @@ public class HlsDownloader{
Interlocked.Add(ref _data.TotalBytes, dec.Length); Interlocked.Add(ref _data.TotalBytes, dec.Length);
} }
} else{ } else{
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries); part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
dec = part; dec = part;
if (dec != null){ if (dec != null){
Interlocked.Add(ref _data.BytesDownloaded, dec.Length); Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
@ -646,7 +704,7 @@ public class HlsDownloader{
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl); string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
if (!_data.Keys.ContainsKey(kUri)){ if (!_data.Keys.ContainsKey(kUri)){
try{ try{
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries); var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries, _cancellationToken);
if (rkey == null || rkey.Length != 16){ if (rkey == null || rkey.Length != 16){
throw new Exception("Key not fully downloaded or is incorrect."); throw new Exception("Key not fully downloaded or is incorrect.");
} }
@ -662,7 +720,8 @@ public class HlsDownloader{
return _data.Keys[kUri]; return _data.Keys[kUri];
} }
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount){ public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount, CancellationToken cancellationToken){
cancellationToken.ThrowIfCancellationRequested();
// Handle local file URI // Handle local file URI
if (uri.StartsWith("file://")){ if (uri.StartsWith("file://")){
string path = new Uri(uri).LocalPath; string path = new Uri(uri).LocalPath;
@ -680,17 +739,18 @@ public class HlsDownloader{
request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent); request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent);
} }
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount); return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount, cancellationToken);
} }
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){ private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount, CancellationToken cancellationToken){
HttpResponseMessage response; HttpResponseMessage response;
for (int attempt = 0; attempt < retryCount + 1; attempt++){ for (int attempt = 0; attempt < retryCount + 1; attempt++){
cancellationToken.ThrowIfCancellationRequested();
using (var request = CloneHttpRequestMessage(requestPara)){ using (var request = CloneHttpRequestMessage(requestPara)){
try{ try{
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return await ReadContentAsByteArrayAsync(response.Content); return await ReadContentAsByteArrayAsync(response.Content, cancellationToken);
} catch (Exception ex) when (ex is HttpRequestException or IOException){ } catch (Exception ex) when (ex is HttpRequestException or IOException){
// Log retry attempts // Log retry attempts
string partType = isKey ? "Key" : "Part"; string partType = isKey ? "Key" : "Part";
@ -700,7 +760,7 @@ public class HlsDownloader{
if (attempt == retryCount) if (attempt == retryCount)
throw; // rethrow after last retry throw; // rethrow after last retry
await Task.Delay(_data.WaitTime); await Task.Delay(_data.WaitTime, cancellationToken);
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:"); Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
Console.Error.WriteLine($"\tType: {ex.GetType()}"); Console.Error.WriteLine($"\tType: {ex.GetType()}");
@ -713,14 +773,14 @@ public class HlsDownloader{
return null; // Should not reach here return null; // Should not reach here
} }
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content){ private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content, CancellationToken cancellationToken){
using (var memoryStream = new MemoryStream()) using (var memoryStream = new MemoryStream())
using (var contentStream = await content.ReadAsStreamAsync()) using (var contentStream = await content.ReadAsStreamAsync(cancellationToken))
using (var throttledStream = new ThrottledStream(contentStream)){ using (var throttledStream = new ThrottledStream(contentStream)){
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int bytesRead; int bytesRead;
while ((bytesRead = await throttledStream.ReadAsync(buffer, 0, buffer.Length)) > 0){ while ((bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0){
await memoryStream.WriteAsync(buffer, 0, bytesRead); await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
} }
return memoryStream.ToArray(); return memoryStream.ToArray();
@ -757,7 +817,7 @@ public class HlsDownloader{
} }
public static class HttpContentExtensions{ public static class HttpContentExtensions{
public static HttpContent Clone(this HttpContent content){ public static HttpContent? Clone(this HttpContent? content){
if (content == null) return null; if (content == null) return null;
var memStream = new MemoryStream(); var memStream = new MemoryStream();
content.CopyToAsync(memStream).Wait(); content.CopyToAsync(memStream).Wait();

View file

@ -24,14 +24,40 @@ using CRD.Utils.Http;
using CRD.Utils.JsonConv; using CRD.Utils.JsonConv;
using CRD.Utils.Parser; using CRD.Utils.Parser;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Microsoft.Win32; using Microsoft.Win32;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using NuGet.Versioning;
namespace CRD.Utils; namespace CRD.Utils;
public class Helpers{ public class Helpers{
private static readonly Regex ClientVersionRegex = new(@"(?:ANDROIDTV|Crunchyroll)/(?<version>[0-9]+(?:[._][0-9]+)*)", RegexOptions.Compiled);
public static string? ExtractClientVersion(string? userAgent){
if (string.IsNullOrWhiteSpace(userAgent)){
return null;
}
var match = ClientVersionRegex.Match(userAgent);
return match.Success ? match.Groups["version"].Value : null;
}
public static int CompareClientVersions(string? left, string? right){
var leftVersion = ParseClientVersion(left);
var rightVersion = ParseClientVersion(right);
return VersionComparer.Version.Compare(leftVersion, rightVersion);
}
private static NuGetVersion ParseClientVersion(string? version){
return NuGetVersion.TryParse(version?.Replace('_', '.') ?? "", out var nuGetVersion)
? nuGetVersion
: NuGetVersion.Parse("0.0.0");
}
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){ public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
try{ try{
serializerSettings ??= new JsonSerializerSettings(); serializerSettings ??= new JsonSerializerSettings();
@ -62,6 +88,19 @@ public class Helpers{
return clone; return clone;
} }
public static int GetRetryDelaySeconds(CrDownloadOptions options, int retryAttemptCount){
return GetRetryDelaySeconds(options.PlaybackRateLimitRetryDelaySeconds, options.RetryMaxDelaySeconds, retryAttemptCount);
}
public static int GetRetryDelaySeconds(int baseDelaySeconds, int maxDelaySeconds, int retryAttemptCount){
int baseDelay = Math.Max(1, baseDelaySeconds);
int maxDelay = Math.Max(baseDelay, maxDelaySeconds);
int attempt = Math.Max(0, retryAttemptCount);
double delay = baseDelay * Math.Pow(2, attempt);
return (int)Math.Min(maxDelay, delay);
}
public static T? DeepCopy<T>(T obj){ public static T? DeepCopy<T>(T obj){
var settings = new JsonSerializerSettings{ var settings = new JsonSerializerSettings{
ContractResolver = new DefaultContractResolver{ ContractResolver = new DefaultContractResolver{
@ -193,7 +232,7 @@ public class Helpers{
} }
} }
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command){ public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command, CancellationToken cancellationToken = default){
try{ try{
using (var process = new Process()){ using (var process = new Process()){
process.StartInfo.FileName = bin; process.StartInfo.FileName = bin;
@ -224,7 +263,17 @@ public class Helpers{
process.BeginOutputReadLine(); process.BeginOutputReadLine();
process.BeginErrorReadLine(); process.BeginErrorReadLine();
await process.WaitForExitAsync(); await using var registration = cancellationToken.Register(() => {
try{
if (!process.HasExited){
process.Kill(true);
}
} catch{
// ignored
}
});
await process.WaitForExitAsync(cancellationToken);
bool isSuccess = process.ExitCode == 0; bool isSuccess = process.ExitCode == 0;
@ -236,69 +285,162 @@ public class Helpers{
} }
} }
public static void DeleteFile(string filePath){ public static bool DeleteFile(string filePath, int maxRetries = 5, int delayMs = 150){
if (string.IsNullOrEmpty(filePath)){ if (string.IsNullOrEmpty(filePath)){
return; return false;
} }
try{ for (int attempt = 0; attempt < maxRetries; attempt++){
if (File.Exists(filePath)){ try{
if (!File.Exists(filePath)){
return true;
}
File.Delete(filePath); File.Delete(filePath);
return true;
} catch (Exception ex) when (attempt < maxRetries - 1 && (ex is IOException || ex is UnauthorizedAccessException)){
Thread.Sleep(delayMs * (attempt + 1));
} catch (Exception ex){
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
return false;
} }
} catch (Exception ex){
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
} }
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: file remained locked after {maxRetries} attempts.");
return false;
}
public static string GetAvailableDestinationPath(string destinationPath){
if (!File.Exists(destinationPath)){
return destinationPath;
}
var directory = Path.GetDirectoryName(destinationPath) ?? string.Empty;
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(destinationPath);
var extension = Path.GetExtension(destinationPath);
var counter = 1;
string candidatePath;
do{
candidatePath = Path.Combine(directory, $"{fileNameWithoutExtension}({counter}){extension}");
counter++;
} while (File.Exists(candidatePath));
return candidatePath;
} }
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){ public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
Process? process = null;
DataReceivedEventHandler? outputHandler = null;
DataReceivedEventHandler? errorHandler = null;
try{ try{
using (var process = new Process()){ process = new Process{
process.StartInfo.WorkingDirectory = workingDir; StartInfo = new ProcessStartInfo{
process.StartInfo.FileName = bin; WorkingDirectory = workingDir,
process.StartInfo.Arguments = command; FileName = bin,
process.StartInfo.RedirectStandardOutput = true; Arguments = command,
process.StartInfo.RedirectStandardError = true; RedirectStandardOutput = true,
process.StartInfo.UseShellExecute = false; RedirectStandardError = true,
process.StartInfo.CreateNoWindow = true; UseShellExecute = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.OutputDataReceived += (sender, e) => { outputHandler = (_, e) => {
if (!string.IsNullOrEmpty(e.Data)){ if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data); Console.WriteLine(e.Data);
} }
}; };
process.ErrorDataReceived += (sender, e) => { errorHandler = (_, e) => {
if (!string.IsNullOrEmpty(e.Data)){ if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine($"{e.Data}"); Console.Error.WriteLine(e.Data);
} }
}; };
process.Start(); process.OutputDataReceived += outputHandler;
process.ErrorDataReceived += errorHandler;
process.BeginOutputReadLine(); process.Start();
process.BeginErrorReadLine();
await process.WaitForExitAsync(); process.BeginOutputReadLine();
process.BeginErrorReadLine();
bool isSuccess = process.ExitCode == 0; await process.WaitForExitAsync();
process.WaitForExit();
return (IsOk: isSuccess, ErrorCode: process.ExitCode); return (IsOk: process.ExitCode == 0, ErrorCode: process.ExitCode);
}
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}"); Console.Error.WriteLine($"An error occurred: {ex.Message}");
return (IsOk: false, ErrorCode: -1); return (IsOk: false, ErrorCode: -1);
} finally{
if (process != null){
if (outputHandler != null){
process.OutputDataReceived -= outputHandler;
}
if (errorHandler != null){
process.ErrorDataReceived -= errorHandler;
}
process.Dispose();
}
} }
} }
private static IEnumerable<string> GetQualityOption(VideoPreset preset){ public static IEnumerable<string> GetQualityOption(VideoPreset preset){
if (preset.Crf is -1)
return [];
var q = preset.Crf.ToString();
return preset.Codec switch{ return preset.Codec switch{
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()], "h264_nvenc" or "hevc_nvenc"
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()], => preset.Crf is >= 0 and <= 51 ? ["-cq", q] : [],
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
_ =>["-crf", preset.Crf.ToString()] "h264_qsv" or "hevc_qsv"
=> preset.Crf is >= 1 and <= 51 ? ["-global_quality", q] : [],
"h264_amf"
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q, "-qp_b", q] : [],
"hevc_amf"
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q] : [],
_ // libx264/libx265/etc.
=> preset.Crf >= 0 ? ["-crf", q] : []
}; };
} }
public static List<string> BuildFFmpegArgsForPreset(string inputFilePath, VideoPreset preset, string outputFilePath){
var args = new List<string>{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
};
if (!string.IsNullOrWhiteSpace(preset.Codec)){
args.Add("-c:v");
args.Add(preset.Codec);
args.AddRange(GetQualityOption(preset));
args.Add("-vf");
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
}
foreach (var param in preset.AdditionalParameters){
args.AddRange(SplitArguments(param));
}
args.Add(outputFilePath);
return args;
}
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync( public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
string inputFilePath, string inputFilePath,
VideoPreset preset, VideoPreset preset,
@ -311,29 +453,7 @@ public class Helpers{
string tempOutput = Path.Combine(dir, $"{name}_output{ext}"); string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath); TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
var args = BuildFFmpegArgsForPreset(inputFilePath, preset, tempOutput);
var args = new List<string>{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
};
if (!string.IsNullOrWhiteSpace(preset.Codec)){
args.Add("-c:v");
args.Add(preset.Codec);
}
args.AddRange(GetQualityOption(preset));
args.Add("-vf");
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
foreach (var param in preset.AdditionalParameters){
args.AddRange(SplitArguments(param));
}
args.Add(tempOutput);
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args); string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
int exitCode; int exitCode;
@ -409,7 +529,7 @@ public class Helpers{
return args; return args;
} }
private static string BuildCommandString(string exe, IEnumerable<string> args){ public static string BuildCommandString(string exe, IEnumerable<string> args){
static string Quote(string s){ static string Quote(string s){
if (string.IsNullOrWhiteSpace(s)) if (string.IsNullOrWhiteSpace(s))
return "\"\""; return "\"\"";

View file

@ -112,9 +112,10 @@ public class HttpClientReq{
return handler; return handler;
} }
public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null, public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
bool allowChallengeBypass = true){ bool allowChallengeBypass = true){
string content = string.Empty; string content = string.Empty;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try{ try{
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){ if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
AttachCookies(request, cookieStore); AttachCookies(request, cookieStore);
@ -131,7 +132,7 @@ public class HttpClientReq{
retryRequest, GetCookiesForRequest(cookieStore)); retryRequest, GetCookiesForRequest(cookieStore));
if (!solverResult.IsOk){ if (!solverResult.IsOk){
return (false, solverResult.ResponseContent, "Challenge bypass failed"); return (false, solverResult.ResponseContent, "Challenge bypass failed",headers);
} }
// foreach (var cookie in solverResult.Cookies){ // foreach (var cookie in solverResult.Cookies){
@ -139,30 +140,36 @@ public class HttpClientReq{
// AddCookie(cookie.Domain, cookie, cookieStore); // AddCookie(cookie.Domain, cookie, cookieStore);
// } // }
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), ""); return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "",headers);
} }
return (false, content, "Cloudflare challenge detected"); return (false, content, "Cloudflare challenge detected",headers);
} }
content = await response.Content.ReadAsStringAsync(); content = await response.Content.ReadAsStringAsync();
foreach (var header in response.Headers)
headers[header.Key] = string.Join(", ", header.Value);
foreach (var header in response.Content.Headers)
headers[header.Key] = string.Join(", ", header.Value);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
CaptureResponseCookies(response, request.RequestUri!, cookieStore); CaptureResponseCookies(response, request.RequestUri!, cookieStore);
return (IsOk: true, ResponseContent: content, error: ""); return (IsOk: true, ResponseContent: content, error: "",headers);
} catch (Exception e){ } catch (Exception e){
if (!suppressError){ if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
} }
return (IsOk: false, ResponseContent: content, error: ""); return (IsOk: false, ResponseContent: content, error: "",headers);
} }
} }
public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){ public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured"); if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured",[]);
string content = string.Empty; string content = string.Empty;
try{ try{
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []); var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
@ -170,13 +177,13 @@ public class HttpClientReq{
content = flareSolverrResponses.ResponseContent; content = flareSolverrResponses.ResponseContent;
return (flareSolverrResponses.IsOk, ResponseContent: content, error: ""); return (flareSolverrResponses.IsOk, ResponseContent: content, error: "",[]);
} catch (Exception e){ } catch (Exception e){
if (!suppressError){ if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
} }
return (IsOk: false, ResponseContent: content, error: ""); return (IsOk: false, ResponseContent: content, error: "",[]);
} }
} }

View file

@ -2,13 +2,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Muxing.Structs; using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Views; using CRD.Views;
@ -108,55 +108,27 @@ public class FontsManager{
{ "Webdings", "webdings.ttf" } { "Webdings", "webdings.ttf" }
}; };
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
private readonly FontIndex index = new(); private readonly FontIndex index = new();
private int _fontSourceNoticePrinted;
private void EnsureIndex(string fontsDir){ private void EnsureIndex(string fontsDir){
index.Rebuild(fontsDir); index.Rebuild(GetFontSearchDirectories(fontsDir));
} }
public async Task GetFontsAsync(){ public Task GetFontsAsync(){
Console.WriteLine("Downloading fonts..."); try{
var fonts = Fonts.Values.ToList(); Directory.CreateDirectory(CfgManager.PathFONTS_DIR);
} catch (Exception e){
foreach (var font in fonts){ Console.Error.WriteLine($"Failed to create fonts directory '{CfgManager.PathFONTS_DIR}': {e.Message}");
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
continue;
}
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
File.Delete(fontLoc);
try{
if (!Directory.Exists(fontFolder))
Directory.CreateDirectory(fontFolder!);
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font;
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
} }
Console.WriteLine("All required fonts downloaded!"); if (Interlocked.Exchange(ref _fontSourceNoticePrinted, 1) == 0){
Console.WriteLine("Crunchyroll-hosted subtitle fonts are no longer available.");
Console.WriteLine($"Font muxing now uses local fonts from '{CfgManager.PathFONTS_DIR}' and system font directories.");
Console.WriteLine("Copy any missing subtitle fonts into the local fonts folder if muxing reports them as missing.");
}
return Task.CompletedTask;
} }
public static List<string> ExtractFontsFromAss(string ass, bool checkTypesettingFonts){ public static List<string> ExtractFontsFromAss(string ass, bool checkTypesettingFonts){
@ -273,7 +245,7 @@ public class FontsManager{
} }
if (missing.Count > 0) if (missing.Count > 0)
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}"); MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}\n\nAdd the missing font files to:\n{CfgManager.PathFONTS_DIR}");
return fontsList; return fontsList;
} }
@ -288,8 +260,8 @@ public class FontsManager{
return true; return true;
if (Fonts.TryGetValue(req, out var crFile)){ if (Fonts.TryGetValue(req, out var crFile)){
var p = Path.Combine(fontsDir, crFile); var p = FindKnownFontFile(crFile, fontsDir);
if (File.Exists(p)){ if (!string.IsNullOrEmpty(p)){
resolvedPath = p; resolvedPath = p;
return true; return true;
} }
@ -303,27 +275,44 @@ public class FontsManager{
return true; return true;
if (Fonts.TryGetValue(family, out var crFamilyFile)){ if (Fonts.TryGetValue(family, out var crFamilyFile)){
var p = Path.Combine(fontsDir, crFamilyFile); var p = FindKnownFontFile(crFamilyFile, fontsDir);
if (File.Exists(p)){ if (!string.IsNullOrEmpty(p)){
resolvedPath = p; resolvedPath = p;
return true; return true;
} }
} }
} }
var reqNoSpace = RemoveSpaces(req);
foreach (var kv in Fonts){
if (RemoveSpaces(kv.Key).Equals(reqNoSpace, StringComparison.OrdinalIgnoreCase)){
var p = FindKnownFontFile(kv.Value, fontsDir);
if (!string.IsNullOrEmpty(p)){
resolvedPath = p;
isExactMatch = false;
return true;
}
}
}
return false; return false;
} }
private static string StripStyleSuffix(string name){ private static string StripStyleSuffix(string name){
var n = name; var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$", var styleWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase){
"", RegexOptions.IgnoreCase).Trim(); "Bold", "Italic", "Oblique", "Regular", "Black",
"Light", "Medium", "Semi", "Condensed"
};
return n; var filtered = parts.Where(p => !styleWords.Contains(p)).ToList();
return filtered.Count > 0 ? string.Join(" ", filtered) : name;
} }
public static string NormalizeFontKey(string s){ private static string NormalizeFontKey(string s){
if (string.IsNullOrWhiteSpace(s)) if (string.IsNullOrWhiteSpace(s))
return string.Empty; return string.Empty;
@ -332,17 +321,24 @@ public class FontsManager{
if (s.StartsWith("@")) if (s.StartsWith("@"))
s = s.Substring(1); s = s.Substring(1);
// Convert camel case (TimesNewRoman → Times New Roman)
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1"); s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
// unify separators
s = s.Replace('_', ' ').Replace('-', ' '); s = s.Replace('_', ' ').Replace('-', ' ');
s = Regex.Replace(s, @"\s+", " ").Trim(); // remove MT suffix (ArialMT → Arial)
s = Regex.Replace(s, @"MT$", "", RegexOptions.IgnoreCase);
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase); // collapse spaces
s = Regex.Replace(s, @"\s+", " ").Trim();
return s; return s;
} }
private static string RemoveSpaces(string s)
=> s.Replace(" ", "");
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){ private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
var baseName = Path.GetFileName(path); var baseName = Path.GetFileName(path);
@ -356,23 +352,79 @@ public class FontsManager{
return $"{hash}-{baseName}"; return $"{hash}-{baseName}";
} }
private static IEnumerable<string> GetFontSearchDirectories(string fontsDir){
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var paths = new List<string>();
void AddIfUsable(string? dir){
if (string.IsNullOrWhiteSpace(dir))
return;
try{
var fullPath = Path.GetFullPath(dir);
if (Directory.Exists(fullPath) && seen.Add(fullPath))
paths.Add(fullPath);
} catch{
// ignore invalid paths
}
}
AddIfUsable(fontsDir);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
AddIfUsable(Environment.GetFolderPath(Environment.SpecialFolder.Fonts));
} else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)){
AddIfUsable("/System/Library/Fonts");
AddIfUsable("/Library/Fonts");
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Fonts"));
} else{
AddIfUsable("/usr/share/fonts");
AddIfUsable("/usr/local/share/fonts");
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"));
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "fonts"));
}
return paths;
}
private static string FindKnownFontFile(string fileName, string fontsDir){
foreach (var dir in GetFontSearchDirectories(fontsDir)){
var path = Path.Combine(dir, fileName);
if (File.Exists(path))
return path;
}
return string.Empty;
}
private sealed class FontIndex{ private sealed class FontIndex{
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
public void Rebuild(string fontsDir){ public void Rebuild(IEnumerable<string> fontDirs){
map.Clear(); map.Clear();
if (!Directory.Exists(fontsDir)) return; foreach (var fontsDir in fontDirs){
if (!Directory.Exists(fontsDir))
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
continue; continue;
foreach (var desc in LoadDescriptions(path)){ try{
foreach (var alias in BuildAliases(desc)){ foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
Add(alias, path); var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
continue;
try{
foreach (var desc in LoadDescriptions(path)){
foreach (var alias in BuildAliases(desc)){
Add(alias, path);
}
}
} catch (Exception e){
Console.Error.WriteLine($"Failed to inspect font '{path}': {e.Message}");
}
} }
} catch (Exception e){
Console.Error.WriteLine($"Failed to scan font directory '{fontsDir}': {e.Message}");
} }
} }
} }
@ -440,9 +492,9 @@ public class FontsManager{
} }
private static IEnumerable<string> BuildAliases(FontDescription d){ private static IEnumerable<string> BuildAliases(FontDescription d){
var family = d.FontFamilyInvariantCulture.Trim(); var family = d.FontFamilyInvariantCulture?.Trim() ?? string.Empty;
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic var sub = d.FontSubFamilyNameInvariantCulture?.Trim() ?? string.Empty; // Regular/Bold/Italic
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily" var full = d.FontNameInvariantCulture?.Trim() ?? string.Empty; // "Family Subfamily"
if (!string.IsNullOrWhiteSpace(family)) yield return family; if (!string.IsNullOrWhiteSpace(family)) yield return family;
if (!string.IsNullOrWhiteSpace(full)) yield return full; if (!string.IsNullOrWhiteSpace(full)) yield return full;

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Utils.Muxing.Commands; using CRD.Utils.Muxing.Commands;
using CRD.Utils.Muxing.Structs; using CRD.Utils.Muxing.Structs;
@ -27,7 +28,7 @@ public class Merger{
} }
public async Task<bool> Merge(string type, string bin){ public async Task<bool> Merge(string type, string bin, CancellationToken cancellationToken = default){
string command = type switch{ string command = type switch{
"ffmpeg" => FFmpeg(), "ffmpeg" => FFmpeg(),
"mkvmerge" => MkvMerge(), "mkvmerge" => MkvMerge(),
@ -40,7 +41,7 @@ public class Merger{
} }
Console.WriteLine($"[{type}] Started merging"); Console.WriteLine($"[{type}] Started merging");
var result = await Helpers.ExecuteCommandAsync(bin, command); var result = await Helpers.ExecuteCommandAsync(bin, command, cancellationToken);
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){ if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning"); Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");

View file

@ -213,7 +213,7 @@ public class SyncingHelper{
Pixels = GetPixelsArray(f.FilePath) Pixels = GetPixelsArray(f.FilePath)
}).ToList(); }).ToList();
var delay = 0.0; var delay = double.NaN;
foreach (var baseFrame in baseFrames){ foreach (var baseFrame in baseFrames){
var baseFramePixels = GetPixelsArray(baseFrame.FilePath); var baseFramePixels = GetPixelsArray(baseFrame.FilePath);

View file

@ -14,7 +14,9 @@ public class VideoSyncer{
public static async Task<(double offSet, double startOffset, double endOffset, double lengthDiff)> ProcessVideo(string baseVideoPath, string compareVideoPath){ public static async Task<(double offSet, double startOffset, double endOffset, double lengthDiff)> ProcessVideo(string baseVideoPath, string compareVideoPath){
string baseFramesDir, baseFramesDirEnd; string baseFramesDir, baseFramesDirEnd;
string compareFramesDir, compareFramesDirEnd; string compareFramesDir, compareFramesDirEnd;
string cleanupDir; string cleanupDir = string.Empty;
double baseEndWindowOffset = 0;
double compareEndWindowOffset = 0;
try{ try{
var tempDir = CfgManager.PathTEMP_DIR; var tempDir = CfgManager.PathTEMP_DIR;
string uuid = Guid.NewGuid().ToString(); string uuid = Guid.NewGuid().ToString();
@ -46,8 +48,13 @@ public class VideoSyncer{
return (-100, 0, 0, 0); return (-100, 0, 0, 0);
} }
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseVideoDurationTimeSpan.Value.TotalSeconds - 360, 360); var baseEndWindowDuration = Math.Min(360, baseVideoDurationTimeSpan.Value.TotalSeconds);
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareVideoDurationTimeSpan.Value.TotalSeconds - 360, 360); var compareEndWindowDuration = Math.Min(360, compareVideoDurationTimeSpan.Value.TotalSeconds);
baseEndWindowOffset = Math.Max(0, baseVideoDurationTimeSpan.Value.TotalSeconds - baseEndWindowDuration);
compareEndWindowOffset = Math.Max(0, compareVideoDurationTimeSpan.Value.TotalSeconds - compareEndWindowDuration);
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseEndWindowOffset, baseEndWindowDuration);
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareEndWindowOffset, compareEndWindowDuration);
if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){ if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){
Console.Error.WriteLine("Failed to extract Frames to Compare"); Console.Error.WriteLine("Failed to extract Frames to Compare");
@ -57,24 +64,24 @@ public class VideoSyncer{
// Load frames from start of the videos // Load frames from start of the videos
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{ var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
FilePath = fp, FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate) Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate, 0)
}).ToList(); }).OrderBy(frame => frame.Time).ToList();
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{ var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
FilePath = fp, FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate) Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate, 0)
}).ToList(); }).OrderBy(frame => frame.Time).ToList();
// Load frames from end of the videos // Load frames from end of the videos
var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{ var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{
FilePath = fp, FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate) Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate, baseEndWindowOffset)
}).ToList(); }).OrderBy(frame => frame.Time).ToList();
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{ var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
FilePath = fp, FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate) Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate, compareEndWindowOffset)
}).ToList(); }).OrderBy(frame => frame.Time).ToList();
// Calculate offsets // Calculate offsets
@ -83,13 +90,14 @@ public class VideoSyncer{
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000; var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
endOffset += lengthDiff; if (double.IsNaN(startOffset) || double.IsNaN(endOffset)){
Console.Error.WriteLine("Couldn't find enough matching frames to sync dub.");
return (-100, startOffset, endOffset, lengthDiff);
}
Console.WriteLine($"Start offset: {startOffset} seconds"); Console.WriteLine($"Start offset: {startOffset} seconds");
Console.WriteLine($"End offset: {endOffset} seconds"); Console.WriteLine($"End offset: {endOffset} seconds");
CleanupDirectory(cleanupDir);
baseFramesStart.Clear(); baseFramesStart.Clear();
baseFramesEnd.Clear(); baseFramesEnd.Clear();
compareFramesStart.Clear(); compareFramesStart.Clear();
@ -112,21 +120,23 @@ public class VideoSyncer{
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine(e); Console.Error.WriteLine(e);
return (-100, 0, 0, 0); return (-100, 0, 0, 0);
} finally{
CleanupDirectory(cleanupDir);
} }
} }
private static void CleanupDirectory(string dirPath){ private static void CleanupDirectory(string dirPath){
if (Directory.Exists(dirPath)){ if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)){
Directory.Delete(dirPath, true); Directory.Delete(dirPath, true);
} }
} }
private static double GetTimeFromFileName(string fileName, double frameRate){ private static double GetTimeFromFileName(string fileName, double frameRate, double timeOffset){
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)"); var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
if (match.Success){ if (match.Success){
return int.Parse(match.Groups[1].Value) / frameRate; return timeOffset + int.Parse(match.Groups[1].Value) / frameRate;
} }
return 0; return timeOffset;
} }
} }

View file

@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils.Notifications;
public interface INotificationProvider{
NotificationProviderType Type{ get; }
Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Notifications.Providers;
namespace CRD.Utils.Notifications;
public class NotificationDispatcher{
public static NotificationDispatcher Instance{ get; } = new();
private readonly IReadOnlyDictionary<NotificationProviderType, INotificationProvider> providers;
private NotificationDispatcher(){
var providerInstances = new INotificationProvider[]{
new SoundNotificationProvider(),
new ExecuteNotificationProvider(),
new WebhookNotificationProvider()
};
providers = providerInstances.ToDictionary(provider => provider.Type);
}
public async Task PublishAsync(NotificationSettings? settings, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){
await PublishWithResultAsync(settings, notificationEvent, cancellationToken);
}
public async Task<bool> PublishWithResultAsync(NotificationSettings? settings, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){
if (settings?.Providers == null || settings.Providers.Count == 0){
return false;
}
var sentSuccessfully = false;
foreach (var config in settings.Providers.Where(provider => provider.Enabled && provider.Handles(notificationEvent.Type))){
if (!providers.TryGetValue(config.Type, out var provider)){
continue;
}
try{
await provider.SendAsync(config, notificationEvent, cancellationToken);
sentSuccessfully = true;
} catch (Exception exception){
Console.Error.WriteLine($"Failed to send {config.Type} notification: {exception}");
}
}
return sentSuccessfully;
}
}

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace CRD.Utils.Notifications;
public class NotificationEvent{
public NotificationEventType Type{ get; set; }
public string Title{ get; set; } = string.Empty;
public string Message{ get; set; } = string.Empty;
public DateTimeOffset TimestampUtc{ get; set; } = DateTimeOffset.UtcNow;
public Dictionary<string, string> Metadata{ get; set; } = [];
}

View file

@ -0,0 +1,10 @@
namespace CRD.Utils.Notifications;
public enum NotificationEventType{
QueueFinished,
DownloadFinished,
DownloadFailed,
TrackedSeriesEpisodeReleased,
LoginExpired,
UpdateAvailable
}

View file

@ -0,0 +1,37 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CRD.Utils.Notifications;
public class NotificationProviderConfig{
[JsonProperty("type")]
public NotificationProviderType Type{ get; set; }
[JsonProperty("enabled")]
public bool Enabled{ get; set; }
[JsonProperty("events")]
public List<NotificationEventType> Events{ get; set; } = [];
[JsonProperty("path")]
public string Path{ get; set; } = string.Empty;
[JsonProperty("url")]
public string Url{ get; set; } = string.Empty;
[JsonProperty("method")]
public string Method{ get; set; } = "POST";
[JsonProperty("headers")]
public Dictionary<string, string> Headers{ get; set; } = [];
[JsonProperty("content_type")]
public string ContentType{ get; set; } = "application/json";
[JsonProperty("body_template")]
public string BodyTemplate{ get; set; } = string.Empty;
public bool Handles(NotificationEventType eventType){
return Events.Count == 0 || Events.Contains(eventType);
}
}

View file

@ -0,0 +1,7 @@
namespace CRD.Utils.Notifications;
public enum NotificationProviderType{
Sound,
Execute,
Webhook
}

View file

@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Utils.Structs.Crunchyroll;
namespace CRD.Utils.Notifications;
public class NotificationPublisher{
public static NotificationPublisher Instance{ get; } = new();
private bool loginExpiredNotificationSent;
private string notifiedUpdateVersion = string.Empty;
public Task PublishDownloadFailedAsync(NotificationSettings? settings, CrunchyEpMeta data, string? error = null){
return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{
Type = NotificationEventType.DownloadFailed,
Title = "Download failed",
Message = string.IsNullOrWhiteSpace(error)
? $"Failed to download {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}."
: $"Failed to download {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}: {error}",
Metadata = BuildMetadata(data, error)
});
}
public Task PublishDownloadFinishedAsync(NotificationSettings? settings, CrunchyEpMeta data){
return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{
Type = NotificationEventType.DownloadFinished,
Title = "Download finished",
Message = $"Finished processing {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}.",
Metadata = BuildMetadata(data)
});
}
public Task PublishQueueFinishedAsync(NotificationSettings? settings, CrunchyEpMeta data){
return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{
Type = NotificationEventType.QueueFinished,
Title = "Downloads finished",
Message = "All queued downloads have finished processing.",
Metadata = []
});
}
public async Task PublishLoginExpiredAsync(NotificationSettings? settings, string? username, string? endpoint){
if (loginExpiredNotificationSent){
return;
}
loginExpiredNotificationSent = true;
await NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{
Type = NotificationEventType.LoginExpired,
Title = "Crunchyroll login expired",
Message = "The saved Crunchyroll session could not be refreshed. Please log in again.",
Metadata = new Dictionary<string, string>{
["username"] = username ?? string.Empty,
["endpoint"] = endpoint ?? string.Empty
}
});
}
public void ResetLoginExpiredNotification(){
loginExpiredNotificationSent = false;
}
public async Task PublishUpdateAvailableAsync(NotificationSettings? settings, string currentVersion, string latestVersion, string platformName, string downloadUrl){
if (string.Equals(notifiedUpdateVersion, latestVersion, StringComparison.OrdinalIgnoreCase)){
return;
}
notifiedUpdateVersion = latestVersion;
await NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{
Type = NotificationEventType.UpdateAvailable,
Title = "Update available",
Message = $"Version {latestVersion} is available. Current version: {currentVersion}.",
Metadata = new Dictionary<string, string>{
["currentVersion"] = currentVersion,
["latestVersion"] = latestVersion,
["platform"] = platformName,
["downloadUrl"] = downloadUrl
}
});
}
public Task<bool> PublishTrackedSeriesEpisodeReleasedAsync(NotificationSettings? settings, HistorySeries series, HistoryEpisode episode, CrBrowseEpisode? release = null, string? locale = null){
var episodeUrl = BuildEpisodeUrl(release, episode, locale);
var imageUrl = release?.Images?.Thumbnail?.FirstOrDefault()?.FirstOrDefault()?.Source
?? episode.ThumbnailImageUrl
?? string.Empty;
var description = release?.Description
?? episode.EpisodeDescription
?? string.Empty;
var premiumAvailableDate = release?.EpisodeMetadata?.PremiumAvailableDate;
var durationMs = release?.EpisodeMetadata?.DurationMs ?? 0;
return NotificationDispatcher.Instance.PublishWithResultAsync(settings, new NotificationEvent{
Type = NotificationEventType.TrackedSeriesEpisodeReleased,
Title = "Tracked series episode released",
Message = string.IsNullOrWhiteSpace(series.SeriesTitle)
? $"A tracked episode is available: {episode.EpisodeTitle ?? episode.EpisodeId ?? "Unknown episode"}."
: $"A tracked episode is available for {series.SeriesTitle}: {episode.EpisodeTitle ?? episode.EpisodeId ?? "Unknown episode"}.",
Metadata = new Dictionary<string, string>{
["seriesTitle"] = series.SeriesTitle ?? string.Empty,
["seriesId"] = series.SeriesId ?? string.Empty,
["seasonId"] = release?.EpisodeMetadata?.SeasonId ?? string.Empty,
["episodeTitle"] = episode.EpisodeTitle ?? string.Empty,
["episodeId"] = episode.EpisodeId ?? string.Empty,
["episodeNumber"] = episode.Episode ?? string.Empty,
["seasonNumber"] = episode.EpisodeSeasonNum ?? string.Empty,
["releaseDate"] = episode.EpisodeCrPremiumAirDate?.ToString("O") ?? string.Empty,
["premiumAvailableDate"] = premiumAvailableDate?.ToString("O") ?? episode.EpisodeCrPremiumAirDate?.ToString("O") ?? string.Empty,
["episodeUrl"] = episodeUrl,
["imageUrl"] = imageUrl,
["description"] = description,
["durationMs"] = durationMs > 0 ? durationMs.ToString() : string.Empty,
["availableDubs"] = string.Join(", ", episode.HistoryEpisodeAvailableDubLang ?? []),
["availableSubs"] = string.Join(", ", episode.HistoryEpisodeAvailableSoftSubs ?? [])
}
});
}
public void ResetUpdateAvailableNotification(){
notifiedUpdateVersion = string.Empty;
}
private static Dictionary<string, string> BuildMetadata(CrunchyEpMeta data, string? error = null){
var metadata = new Dictionary<string, string>{
["seriesTitle"] = data.SeriesTitle ?? string.Empty,
["seasonTitle"] = data.SeasonTitle ?? string.Empty,
["episodeTitle"] = data.EpisodeTitle ?? string.Empty,
["episodeNumber"] = data.EpisodeNumber ?? string.Empty,
["episodeId"] = data.EpisodeId ?? string.Empty,
["downloadPath"] = data.DownloadPath ?? string.Empty,
["seasonNumber"] = data.Season ?? string.Empty,
["description"] = data.Description ?? string.Empty,
["imageUrl"] = data.Image ?? string.Empty,
["imageUrlLarge"] = data.ImageBig ?? string.Empty,
["downloadSubs"] = string.Join(", ", data.DownloadSubs ?? []),
["downloadDubs"] = string.Join(", ", data.SelectedDubs ?? []),
["hardsub"] = data.Hslang ?? string.Empty,
};
if (!string.IsNullOrWhiteSpace(data.SeriesId)){
metadata["seriesId"] = data.SeriesId;
}
if (!string.IsNullOrWhiteSpace(data.SeasonId)){
metadata["seasonId"] = data.SeasonId;
}
if (!string.IsNullOrWhiteSpace(data.EpisodeId)){
metadata["episodeUrl"] = $"https://www.crunchyroll.com/watch/{data.EpisodeId}";
}
if (!string.IsNullOrWhiteSpace(error)){
metadata["error"] = error;
}
return metadata;
}
private static string BuildEpisodeUrl(CrBrowseEpisode? release, HistoryEpisode episode, string? locale){
var episodeId = release?.Id ?? episode.EpisodeId;
if (string.IsNullOrWhiteSpace(episodeId)){
return string.Empty;
}
var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? "en-US" : locale;
var slugTitle = release?.SlugTitle;
return string.IsNullOrWhiteSpace(slugTitle)
? $"https://www.crunchyroll.com/{normalizedLocale}/watch/{episodeId}"
: $"https://www.crunchyroll.com/{normalizedLocale}/watch/{episodeId}/{slugTitle}";
}
}

View file

@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
namespace CRD.Utils.Notifications;
public class NotificationSettings{
[JsonProperty("providers")]
public List<NotificationProviderConfig> Providers{ get; set; } = [];
public NotificationProviderConfig GetOrCreateProvider(NotificationProviderType type){
var provider = Providers.FirstOrDefault(p => p.Type == type);
if (provider != null){
provider.Events ??= [];
provider.Headers ??= [];
return provider;
}
provider = new NotificationProviderConfig{
Type = type
};
Providers.Add(provider);
return provider;
}
}

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils.Notifications.Providers;
public class ExecuteNotificationProvider : INotificationProvider{
public NotificationProviderType Type => NotificationProviderType.Execute;
public Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){
if (string.IsNullOrWhiteSpace(config.Path)){
return Task.CompletedTask;
}
Helpers.ExecuteFile(config.Path);
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils.Notifications.Providers;
public class SoundNotificationProvider : INotificationProvider{
public NotificationProviderType Type => NotificationProviderType.Sound;
public Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){
if (string.IsNullOrWhiteSpace(config.Path)){
return Task.CompletedTask;
}
var player = new AudioPlayer();
return player.PlayAsync(config.Path);
}
}

View file

@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Http;
using Newtonsoft.Json;
namespace CRD.Utils.Notifications.Providers;
public class WebhookNotificationProvider : INotificationProvider{
public NotificationProviderType Type => NotificationProviderType.Webhook;
public async Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){
if (string.IsNullOrWhiteSpace(config.Url)){
return;
}
using var request = new HttpRequestMessage(new HttpMethod(string.IsNullOrWhiteSpace(config.Method) ? "POST" : config.Method), config.Url);
foreach (var header in config.Headers){
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
var body = BuildBody(config, notificationEvent);
if (!string.IsNullOrEmpty(body)){
request.Content = new StringContent(body, Encoding.UTF8, string.IsNullOrWhiteSpace(config.ContentType) ? "application/json" : config.ContentType);
}
var response = await HttpClientReq.Instance.SendHttpRequest(request, suppressError: false);
if (!response.IsOk){
throw new InvalidOperationException($"Webhook request failed for '{config.Url}'.");
}
}
private static string BuildBody(NotificationProviderConfig config, NotificationEvent notificationEvent){
if (!string.IsNullOrWhiteSpace(config.BodyTemplate)){
return ApplyTemplate(config.BodyTemplate, notificationEvent);
}
var payload = new{
eventType = notificationEvent.Type.ToString(),
title = notificationEvent.Title,
message = notificationEvent.Message,
timestampUtc = notificationEvent.TimestampUtc,
metadata = notificationEvent.Metadata
};
return JsonConvert.SerializeObject(payload);
}
private static string ApplyTemplate(string template, NotificationEvent notificationEvent){
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase){
["eventType"] = notificationEvent.Type.ToString(),
["title"] = notificationEvent.Title,
["message"] = notificationEvent.Message,
["timestampUtc"] = notificationEvent.TimestampUtc.ToString("O")
};
foreach (var pair in notificationEvent.Metadata){
values[pair.Key] = pair.Value;
}
foreach (var pair in values){
template = template.Replace("{{" + pair.Key + "}}", pair.Value, StringComparison.OrdinalIgnoreCase);
}
return template;
}
}

View file

@ -60,7 +60,7 @@ public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposabl
} }
} }
private int running = 0; private int running;
private async Task SafeRunWork(CancellationToken token){ private async Task SafeRunWork(CancellationToken token){
if (Interlocked.Exchange(ref running, 1) == 1){ if (Interlocked.Exchange(ref running, 1) == 1){

View file

@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using CRD.Utils.Structs;
using CRD.ViewModels;
namespace CRD.Utils.QueueManagement;
public sealed class DownloadItemModelCollection{
private readonly ObservableCollection<DownloadItemModel> items = new();
private readonly Dictionary<CrunchyEpMeta, DownloadItemModel> models = new();
public ObservableCollection<DownloadItemModel> Items => items;
public DownloadItemModel? Find(CrunchyEpMeta item){
return models.TryGetValue(item, out var model)
? model
: null;
}
public void Remove(CrunchyEpMeta item){
if (models.Remove(item, out var model)){
items.Remove(model);
} else{
Console.Error.WriteLine("Failed to remove episode from list");
}
}
public void Clear(){
models.Clear();
items.Clear();
}
public void SyncFromQueue(IEnumerable<CrunchyEpMeta> queueItems){
foreach (var queueItem in queueItems){
if (models.TryGetValue(queueItem, out var existingModel)){
existingModel.Refresh();
continue;
}
var newModel = new DownloadItemModel(queueItem);
models.Add(queueItem, newModel);
items.Add(newModel);
_ = newModel.LoadImage();
}
}
}

View file

@ -0,0 +1,78 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils.QueueManagement;
public sealed class ProcessingSlotManager{
private readonly SemaphoreSlim semaphore;
private readonly object syncLock = new();
private int limit;
private int borrowedPermits;
public int Limit{
get{
lock (syncLock){
return limit;
}
}
}
public ProcessingSlotManager(int initialLimit){
if (initialLimit < 0)
throw new ArgumentOutOfRangeException(nameof(initialLimit));
limit = initialLimit;
semaphore = new SemaphoreSlim(
initialCount: initialLimit,
maxCount: int.MaxValue);
}
public Task WaitAsync(CancellationToken cancellationToken = default){
return semaphore.WaitAsync(cancellationToken);
}
public void Release(){
lock (syncLock){
if (borrowedPermits > 0){
borrowedPermits--;
return;
}
semaphore.Release();
}
}
public void SetLimit(int newLimit){
if (newLimit < 0)
throw new ArgumentOutOfRangeException(nameof(newLimit));
lock (syncLock){
if (newLimit == limit)
return;
int delta = newLimit - limit;
if (delta > 0){
int giveBackBorrowed = Math.Min(borrowedPermits, delta);
borrowedPermits -= giveBackBorrowed;
int permitsToRelease = delta - giveBackBorrowed;
if (permitsToRelease > 0)
semaphore.Release(permitsToRelease);
} else{
int permitsToRemove = -delta;
while (permitsToRemove > 0 && semaphore.Wait(0)){
permitsToRemove--;
}
borrowedPermits += permitsToRemove;
}
limit = newLimit;
}
}
}

View file

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
namespace CRD.Utils.QueueManagement;
public sealed class QueuePersistenceManager : IDisposable{
private readonly object syncLock = new();
private readonly QueueManager queueManager;
private Timer? saveTimer;
public QueuePersistenceManager(QueueManager queueManager){
this.queueManager = queueManager ?? throw new ArgumentNullException(nameof(queueManager));
this.queueManager.QueueStateChanged += OnQueueStateChanged;
}
public void RestoreQueue(){
var options = CrunchyrollManager.Instance.CrunOptions;
if (!options.PersistQueue){
CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue);
return;
}
if (!CfgManager.CheckIfFileExists(CfgManager.PathCrQueue))
return;
var savedQueue = CfgManager.ReadJsonFromFile<List<CrunchyEpMeta>>(CfgManager.PathCrQueue);
if (savedQueue == null || savedQueue.Count == 0){
CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue);
return;
}
queueManager.ReplaceQueue(savedQueue.Select(PrepareRestoredItem));
}
public void SaveNow(){
lock (syncLock){
saveTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
PersistQueueSnapshot();
}
public void ScheduleSave(){
lock (syncLock){
if (saveTimer == null){
saveTimer = new Timer(_ => PersistQueueSnapshot(), null, TimeSpan.FromMilliseconds(750), Timeout.InfiniteTimeSpan);
return;
}
saveTimer.Change(TimeSpan.FromMilliseconds(750), Timeout.InfiniteTimeSpan);
}
}
private void OnQueueStateChanged(object? sender, EventArgs e){
ScheduleSave();
}
private void PersistQueueSnapshot(){
var options = CrunchyrollManager.Instance.CrunOptions;
if (!options.PersistQueue){
CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue);
return;
}
var queue = queueManager.GetQueueSnapshot();
if (queue.Count == 0){
CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue);
return;
}
var snapshot = queue
.Select(CloneForPersistence)
.Where(item => item != null)
.ToList();
if (snapshot.Count == 0){
CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue);
return;
}
CfgManager.WriteJsonToFile(CfgManager.PathCrQueue, snapshot);
}
private static CrunchyEpMeta PrepareRestoredItem(CrunchyEpMeta item){
item.Data ??= [];
item.DownloadSubs ??= [];
item.downloadedFiles ??= [];
item.DownloadProgress ??= new DownloadProgress();
if (item.DownloadProgress.RetryAtUtc.HasValue){
if (item.DownloadProgress.RetryAtUtc.Value <= DateTimeOffset.UtcNow){
item.DownloadProgress.ResetForRetry();
} else{
item.DownloadProgress.State = DownloadState.Queued;
item.DownloadProgress.ResumeState = DownloadState.Downloading;
}
} else if (!item.DownloadProgress.IsFinished){
item.DownloadProgress.ResetForRetry();
}
item.RenewCancellationToken();
return item;
}
private static CrunchyEpMeta? CloneForPersistence(CrunchyEpMeta item){
return Helpers.DeepCopy(item);
}
public void Dispose(){
lock (syncLock){
saveTimer?.Dispose();
saveTimer = null;
}
queueManager.QueueStateChanged -= OnQueueStateChanged;
}
}

View file

@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Avalonia.Threading;
namespace CRD.Utils.QueueManagement;
public sealed class UiMutationQueue{
private readonly object syncLock = new();
private readonly Queue<Action> pendingMutations = new();
private readonly Dispatcher dispatcher;
private readonly DispatcherPriority priority;
private bool isProcessing;
private int pumpScheduled;
public UiMutationQueue()
: this(null, DispatcherPriority.Background){
}
public UiMutationQueue(
Dispatcher? dispatcher,
DispatcherPriority priority){
this.dispatcher = dispatcher ?? Dispatcher.UIThread;
this.priority = priority;
}
public void Enqueue(Action mutation){
if (mutation == null)
throw new ArgumentNullException(nameof(mutation));
lock (syncLock){
pendingMutations.Enqueue(mutation);
}
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
return;
dispatcher.Post(ProcessPendingMutations, priority);
}
private void ProcessPendingMutations(){
if (isProcessing)
return;
try{
isProcessing = true;
while (true){
Action? mutation;
lock (syncLock){
mutation = pendingMutations.Count > 0
? pendingMutations.Dequeue()
: null;
}
if (mutation is null)
break;
mutation();
}
} finally{
isProcessing = false;
Interlocked.Exchange(ref pumpScheduled, 0);
bool hasPending;
lock (syncLock){
hasPending = pendingMutations.Count > 0;
}
if (hasPending &&
Interlocked.CompareExchange(ref pumpScheduled, 1, 0) == 0){
dispatcher.Post(ProcessPendingMutations, priority);
}
}
}
}

View file

@ -58,7 +58,7 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
if (match.Success){ if (match.Success){
var locale = match.Groups[1].Value; // Capture the locale part var locale = match.Groups[1].Value; // Capture the locale part
var id = match.Groups[2].Value; // Capture the ID part var id = match.Groups[2].Value; // Capture the ID part
await QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true); await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
} }
} }

View file

@ -1,8 +1,10 @@
using System.Collections.Generic; using System.Collections.Generic;
using CRD.Utils.Http; using CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.ViewModels; using CRD.ViewModels;
using Newtonsoft.Json; using Newtonsoft.Json;
using System;
namespace CRD.Utils.Structs.Crunchyroll; namespace CRD.Utils.Structs.Crunchyroll;
@ -21,6 +23,9 @@ public class CrDownloadOptions{
[JsonProperty("remove_finished_downloads")] [JsonProperty("remove_finished_downloads")]
public bool RemoveFinishedDownload{ get; set; } public bool RemoveFinishedDownload{ get; set; }
[JsonProperty("persist_queue")]
public bool PersistQueue{ get; set; }
[JsonIgnore] [JsonIgnore]
public int Timeout{ get; set; } public int Timeout{ get; set; }
@ -30,6 +35,12 @@ public class CrDownloadOptions{
[JsonProperty("retry_attempts")] [JsonProperty("retry_attempts")]
public int RetryAttempts{ get; set; } public int RetryAttempts{ get; set; }
[JsonProperty("playback_rate_limit_retry_delay_seconds")]
public int PlaybackRateLimitRetryDelaySeconds{ get; set; }
[JsonProperty("retry_max_delay_seconds")]
public int RetryMaxDelaySeconds{ get; set; }
[JsonIgnore] [JsonIgnore]
public string Force{ get; set; } = ""; public string Force{ get; set; } = "";
@ -66,6 +77,9 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_execute_path")] [JsonProperty("download_finished_execute_path")]
public string? DownloadFinishedExecutePath{ get; set; } public string? DownloadFinishedExecutePath{ get; set; }
[JsonProperty("notifications")]
public NotificationSettings? NotificationSettings{ get; set; }
[JsonProperty("download_only_with_all_selected_dubsub")] [JsonProperty("download_only_with_all_selected_dubsub")]
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; } public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
@ -93,6 +107,9 @@ public class CrDownloadOptions{
[JsonProperty("history_include_cr_artists")] [JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; } public bool HistoryIncludeCrArtists{ get; set; }
[JsonProperty("history_remove_missing_episodes")]
public bool HistoryRemoveMissingEpisodes{ get; set; } = true;
[JsonProperty("history_lang")] [JsonProperty("history_lang")]
public string? HistoryLang{ get; set; } public string? HistoryLang{ get; set; }
@ -111,6 +128,12 @@ public class CrDownloadOptions{
[JsonProperty("history_auto_refresh_mode")] [JsonProperty("history_auto_refresh_mode")]
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; } public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
[JsonProperty("history_auto_refresh_add_to_queue")]
public bool HistoryAutoRefreshAddToQueue{ get; set; } = true;
[JsonProperty("tracked_series_release_last_check_utc")]
public DateTime? TrackedSeriesReleaseLastCheckUtc{ get; set; }
[JsonProperty("sonarr_properties")] [JsonProperty("sonarr_properties")]
public SonarrProperties? SonarrProperties{ get; set; } public SonarrProperties? SonarrProperties{ get; set; }
@ -224,6 +247,9 @@ public class CrDownloadOptions{
[JsonProperty("download_part_size")] [JsonProperty("download_part_size")]
public int Partsize{ get; set; } public int Partsize{ get; set; }
[JsonProperty("dub_download_delay_seconds")]
public int DubDownloadDelaySeconds{ get; set; }
[JsonProperty("soft_subs")] [JsonProperty("soft_subs")]
public List<string> DlSubs{ get; set; } =[]; public List<string> DlSubs{ get; set; } =[];
@ -317,6 +343,9 @@ public class CrDownloadOptions{
[JsonProperty("mux_sync_dubs")] [JsonProperty("mux_sync_dubs")]
public bool SyncTiming{ get; set; } public bool SyncTiming{ get; set; }
[JsonProperty("mux_sync_fallback_full_quality")]
public bool SyncTimingFullQualityFallback{ get; set; }
[JsonProperty("mux_sync_hwaccel")] [JsonProperty("mux_sync_hwaccel")]
public string? FfmpegHwAccelFlag{ get; set; } public string? FfmpegHwAccelFlag{ get; set; }
@ -360,4 +389,46 @@ public class CrDownloadOptions{
public bool SearchFetchFeaturedMusic{ get; set; } public bool SearchFetchFeaturedMusic{ get; set; }
#endregion #endregion
public void NormalizeNotificationSettings(){
NotificationSettings ??= new NotificationSettings();
var hasProviders = NotificationSettings.Providers.Count > 0;
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
if (hasProviders){
if (soundProvider.Events.Count == 0){
soundProvider.Events.Add(NotificationEventType.QueueFinished);
}
} else{
soundProvider.Enabled = DownloadFinishedPlaySound;
soundProvider.Path = DownloadFinishedSoundPath ?? string.Empty;
soundProvider.Events.Add(NotificationEventType.QueueFinished);
}
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
if (hasProviders){
if (executeProvider.Events.Count == 0){
executeProvider.Events.Add(NotificationEventType.QueueFinished);
}
} else{
executeProvider.Enabled = DownloadFinishedExecute;
executeProvider.Path = DownloadFinishedExecutePath ?? string.Empty;
executeProvider.Events.Add(NotificationEventType.QueueFinished);
}
SyncLegacyNotificationFields();
}
public void SyncLegacyNotificationFields(){
NotificationSettings ??= new NotificationSettings();
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
DownloadFinishedPlaySound = soundProvider.Enabled;
DownloadFinishedSoundPath = soundProvider.Path;
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
DownloadFinishedExecute = executeProvider.Enabled;
DownloadFinishedExecutePath = executeProvider.Path;
}
} }

View file

@ -246,6 +246,12 @@ public class CrunchyEpisode : IHistorySource{
} }
public bool IsSpecialSeason(){ public bool IsSpecialSeason(){
if (SeasonTitle.Contains("OVA", StringComparison.Ordinal) ||
SeasonTitle.Contains("Special", StringComparison.Ordinal) ||
SeasonTitle.Contains("Extra", StringComparison.Ordinal)){
return true;
}
if (string.IsNullOrEmpty(Identifier)){ if (string.IsNullOrEmpty(Identifier)){
return false; return false;
} }
@ -285,7 +291,20 @@ public class CrunchyEpisode : IHistorySource{
} }
public SeriesType GetSeriesType(){ public SeriesType GetSeriesType(){
return SeriesType.Series; if (string.IsNullOrWhiteSpace(Identifier))
return SeriesType.Series;
var parts = Identifier.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
return SeriesType.Series;
return parts[1] switch{
var p when p.StartsWith("S", StringComparison.OrdinalIgnoreCase) => SeriesType.Series,
var p when p.StartsWith("M", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
var p when p.StartsWith("T", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
var p when parts.Length == 2 && p.StartsWith("E", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
_ => SeriesType.Series
};
} }
public EpisodeType GetEpisodeType(){ public EpisodeType GetEpisodeType(){
@ -373,7 +392,6 @@ public class CrunchyEpMeta{
public string? AbsolutEpisodeNumberE{ get; set; } public string? AbsolutEpisodeNumberE{ get; set; }
public string? Image{ get; set; } public string? Image{ get; set; }
public string? ImageBig{ get; set; } public string? ImageBig{ get; set; }
public bool Paused{ get; set; }
public DownloadProgress DownloadProgress{ get; set; } = new(); public DownloadProgress DownloadProgress{ get; set; } = new();
public List<string>? SelectedDubs{ get; set; } public List<string>? SelectedDubs{ get; set; }
@ -385,6 +403,7 @@ public class CrunchyEpMeta{
public string? DownloadPath{ get; set; } public string? DownloadPath{ get; set; }
public string? VideoQuality{ get; set; } public string? VideoQuality{ get; set; }
public List<string> DownloadSubs{ get; set; } =[]; public List<string> DownloadSubs{ get; set; } =[];
public string? TempFileSuffix{ get; set; }
public bool Music{ get; set; } public bool Music{ get; set; }
public string Resolution{ get; set; } public string Resolution{ get; set; }
@ -399,18 +418,74 @@ public class CrunchyEpMeta{
public bool HighlightAllAvailable{ get; set; } public bool HighlightAllAvailable{ get; set; }
public CancellationTokenSource Cts { get; } = new(); [JsonIgnore]
public CancellationTokenSource Cts { get; private set; } = new();
public void RenewCancellationToken(){
if (!Cts.IsCancellationRequested){
return;
}
Cts.Dispose();
Cts = new CancellationTokenSource();
}
public void CancelDownload(){
if (Cts.IsCancellationRequested){
return;
}
Cts.Cancel();
}
} }
public class DownloadProgress{ public class DownloadProgress{
public bool IsDownloading = false; public DownloadState State{ get; set; } = DownloadState.Queued;
public bool Done = false; public DownloadState ResumeState{ get; set; } = DownloadState.Downloading;
public bool Error = false;
public string Doing = string.Empty; public string Doing = string.Empty;
public DateTimeOffset? RetryAtUtc{ get; set; }
public int RetryAttemptCount{ get; set; }
public int Percent{ get; set; } public int Percent{ get; set; }
public double Time{ get; set; } public double Time{ get; set; }
public double DownloadSpeedBytes{ get; set; } public double DownloadSpeedBytes{ get; set; }
public bool IsQueued => State == DownloadState.Queued;
public bool IsDownloading => State == DownloadState.Downloading;
public bool IsPaused => State == DownloadState.Paused;
public bool IsProcessing => State == DownloadState.Processing;
public bool IsDone => State == DownloadState.Done;
public bool IsError => State == DownloadState.Error;
public bool IsFinished => State is DownloadState.Done or DownloadState.Error;
public bool IsRunnable => State is DownloadState.Queued or DownloadState.Error;
public bool IsWaitingForRetry => RetryAtUtc.HasValue && RetryAtUtc.Value > DateTimeOffset.UtcNow;
public void ResetForRetry(){
State = DownloadState.Queued;
ResumeState = DownloadState.Downloading;
Percent = 0;
Time = 0;
DownloadSpeedBytes = 0;
Doing = string.Empty;
RetryAtUtc = null;
RetryAttemptCount = 0;
}
public void ScheduleRetry(TimeSpan delay, string doing){
State = DownloadState.Queued;
ResumeState = DownloadState.Downloading;
Percent = 0;
Time = 0;
DownloadSpeedBytes = 0;
Doing = doing;
RetryAtUtc = DateTimeOffset.UtcNow.Add(delay);
RetryAttemptCount++;
}
public void ClearRetryState(){
RetryAtUtc = null;
RetryAttemptCount = 0;
}
} }
public class CrunchyEpMetaData{ public class CrunchyEpMetaData{

View file

@ -6,14 +6,22 @@ namespace CRD.Utils.Structs.Crunchyroll;
public class StreamError{ public class StreamError{
[JsonPropertyName("error")] [JsonPropertyName("error")]
public string Error{ get; set; } public string? Error{ get; set; }
[JsonPropertyName("activeStreams")] [JsonPropertyName("activeStreams")]
public List<ActiveStream> ActiveStreams{ get; set; } = new (); public List<ActiveStream> ActiveStreams{ get; set; } = new ();
[JsonIgnore]
public string? RawJson{ get; set; }
public static StreamError? FromJson(string json){ public static StreamError? FromJson(string json){
try{ try{
return Helpers.Deserialize<StreamError>(json,null); var error = Helpers.Deserialize<StreamError>(json,null);
if (error != null){
error.RawJson = json;
}
return error;
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine(e); Console.Error.WriteLine(e);
return null; return null;
@ -23,6 +31,14 @@ public class StreamError{
public bool IsTooManyActiveStreamsError(){ public bool IsTooManyActiveStreamsError(){
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS"; return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
} }
public bool IsRateLimitError(){
return IsPlaybackRateLimitError();
}
public bool IsPlaybackRateLimitError(){
return Error?.Contains("4294") == true || RawJson?.Contains("4294") == true;
}
} }
public class ActiveStream{ public class ActiveStream{

View file

@ -125,6 +125,8 @@ public class DownloadResponse{
public string VideoTitle{ get; set; } public string VideoTitle{ get; set; }
public bool Error{ get; set; } public bool Error{ get; set; }
public string ErrorText{ get; set; } public string ErrorText{ get; set; }
public bool RetrySuggested{ get; set; }
public int RetryDelaySeconds{ get; set; }
} }
public class DownloadedMedia : SxItem{ public class DownloadedMedia : SxItem{
@ -133,6 +135,7 @@ public class DownloadedMedia : SxItem{
public bool IsPrimary{ get; set; } public bool IsPrimary{ get; set; }
public int bitrate{ get; set; } public int bitrate{ get; set; }
public int? Delay{ get; set; }
public bool? Cc{ get; set; } public bool? Cc{ get; set; }
public bool? Signs{ get; set; } public bool? Signs{ get; set; }

View file

@ -34,6 +34,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
[JsonProperty("episode_was_downloaded")] [JsonProperty("episode_was_downloaded")]
public bool WasDownloaded{ get; set; } public bool WasDownloaded{ get; set; }
[JsonProperty("episode_tracked_series_release_notified")]
public bool TrackedSeriesReleaseNotified{ get; set; }
[JsonProperty("episode_special_episode")] [JsonProperty("episode_special_episode")]
public bool SpecialEpisode{ get; set; } public bool SpecialEpisode{ get; set; }
@ -43,6 +46,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
[JsonProperty("episode_type")] [JsonProperty("episode_type")]
public EpisodeType EpisodeType{ get; set; } = EpisodeType.Unknown; public EpisodeType EpisodeType{ get; set; } = EpisodeType.Unknown;
[JsonProperty("episode_series_type")]
public SeriesType EpisodeSeriesType{ get; set; } = SeriesType.Unknown;
[JsonProperty("episode_thumbnail_url")] [JsonProperty("episode_thumbnail_url")]
public string? ThumbnailImageUrl{ get; set; } public string? ThumbnailImageUrl{ get; set; }
@ -86,7 +92,7 @@ public class HistoryEpisode : INotifyPropertyChanged{
public Bitmap? ThumbnailImage{ get; set; } public Bitmap? ThumbnailImage{ get; set; }
[JsonIgnore] [JsonIgnore]
public bool IsImageLoaded{ get; private set; } = false; public bool IsImageLoaded{ get; private set; }
public async Task LoadImage(){ public async Task LoadImage(){
if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl)) if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl))
@ -153,15 +159,15 @@ public class HistoryEpisode : INotifyPropertyChanged{
switch (EpisodeType){ switch (EpisodeType){
case EpisodeType.MusicVideo: case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath); await CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break; break;
case EpisodeType.Concert: case EpisodeType.Concert:
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath); await CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break; break;
case EpisodeType.Episode: case EpisodeType.Episode:
case EpisodeType.Unknown: case EpisodeType.Unknown:
default: default:
await QueueManager.Instance.CrAddEpisodeToQueue(EpisodeId ?? string.Empty, await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang, string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang,
CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode); CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode);
break; break;

View file

@ -53,7 +53,7 @@ public class HistorySeason : INotifyPropertyChanged{
public StringItem? _selectedVideoQualityItem; public StringItem? _selectedVideoQualityItem;
[JsonIgnore] [JsonIgnore]
private bool Loading = false; private bool Loading;
[JsonIgnore] [JsonIgnore]
public StringItem? SelectedVideoQualityItem{ public StringItem? SelectedVideoQualityItem{

View file

@ -81,7 +81,7 @@ public class HistorySeries : INotifyPropertyChanged{
public Bitmap? ThumbnailImage{ get; set; } public Bitmap? ThumbnailImage{ get; set; }
[JsonIgnore] [JsonIgnore]
public bool IsImageLoaded{ get; private set; } = false; public bool IsImageLoaded{ get; private set; }
[JsonIgnore] [JsonIgnore]
public bool FetchingData{ get; set; } public bool FetchingData{ get; set; }
@ -112,7 +112,7 @@ public class HistorySeries : INotifyPropertyChanged{
#region Settings Override #region Settings Override
[JsonIgnore] [JsonIgnore]
private bool Loading = false; private bool Loading;
[JsonIgnore] [JsonIgnore]
public StringItem? _selectedVideoQualityItem; public StringItem? _selectedVideoQualityItem;
@ -350,6 +350,7 @@ public class HistorySeries : INotifyPropertyChanged{
} }
break; break;
case SeriesType.Movie:
case SeriesType.Series: case SeriesType.Series:
case SeriesType.Unknown: case SeriesType.Unknown:
default: default:
@ -396,6 +397,7 @@ public class HistorySeries : INotifyPropertyChanged{
case SeriesType.Artist: case SeriesType.Artist:
Helpers.OpenUrl($"https://www.crunchyroll.com/artist/{SeriesId}"); Helpers.OpenUrl($"https://www.crunchyroll.com/artist/{SeriesId}");
break; break;
case SeriesType.Movie:
case SeriesType.Series: case SeriesType.Series:
case SeriesType.Unknown: case SeriesType.Unknown:
default: default:

View file

@ -15,6 +15,7 @@ using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http; using CRD.Utils.Http;
using CRD.Utils.Notifications;
using Newtonsoft.Json; using Newtonsoft.Json;
using NuGet.Versioning; using NuGet.Versioning;
@ -24,6 +25,7 @@ public class Updater : ObservableObject{
public double Progress; public double Progress;
public bool Failed; public bool Failed;
public string LatestVersion = ""; public string LatestVersion = "";
public List<GithubJson> GhAuthJson = [];
public static Updater Instance{ get; } = new(); public static Updater Instance{ get; } = new();
@ -109,12 +111,19 @@ public class Updater : ObservableObject{
} }
downloadUrl = asset.BrowserDownloadUrl; downloadUrl = asset.BrowserDownloadUrl;
await NotificationPublisher.Instance.PublishUpdateAvailableAsync(
CrunchyrollManager.Instance.CrunOptions.NotificationSettings,
currentVersion.ToString(),
selectedRelease.TagName,
platformName,
downloadUrl);
_ = UpdateChangelogAsync(); _ = UpdateChangelogAsync();
return true; return true;
} }
Console.WriteLine("No updates available."); Console.WriteLine("No updates available.");
NotificationPublisher.Instance.ResetUpdateAvailableNotification();
_ = UpdateChangelogAsync(); _ = UpdateChangelogAsync();
return false; return false;
} }
@ -124,6 +133,25 @@ public class Updater : ObservableObject{
} }
} }
public async Task CheckGhJsonAsync(){
var url = "https://Crunchy-DL.github.io/Crunchy-Downloader/data.json";
try{
HttpClientHandler handler = new HttpClientHandler();
handler.UseProxy = false;
using (var client = new HttpClient(handler)){
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
var response = await client.GetStringAsync(url);
var authList = Helpers.Deserialize<List<GithubJson>>(response, null);
if (authList is{ Count: > 0 }){
GhAuthJson = authList;
}
}
} catch (Exception e){
Console.Error.WriteLine("Failed to get GH CR Auth information");
}
}
public async Task UpdateChangelogAsync(){ public async Task UpdateChangelogAsync(){
var client = HttpClientReq.Instance.GetHttpClient(); var client = HttpClientReq.Instance.GetHttpClient();
client.DefaultRequestHeaders.Add("User-Agent", "C# App"); client.DefaultRequestHeaders.Add("User-Agent", "C# App");
@ -138,7 +166,15 @@ public class Updater : ObservableObject{
LatestVersion = "v1.0.0"; LatestVersion = "v1.0.0";
} }
if (existingVersion == LatestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(LatestVersion.TrimStart('v'))){ if (!NuGetVersion.TryParse(existingVersion.TrimStart('v'), out var existingNuGetVersion)){
existingNuGetVersion = NuGetVersion.Parse("1.0.0");
}
if (!NuGetVersion.TryParse(LatestVersion.TrimStart('v'), out var latestNuGetVersion)){
latestNuGetVersion = NuGetVersion.Parse("1.0.0");
}
if (existingNuGetVersion >= latestNuGetVersion){
Console.WriteLine("CHANGELOG.md is already up to date."); Console.WriteLine("CHANGELOG.md is already up to date.");
return; return;
} }
@ -180,10 +216,16 @@ public class Updater : ObservableObject{
return string.Empty; return string.Empty;
string[] lines = File.ReadAllLines(changelogFilePath); string[] lines = File.ReadAllLines(changelogFilePath);
foreach (string line in lines){ foreach (string line in lines){
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]"); Match match = Regex.Match(line, @"^## \[(v?[^\]]+)\]");
if (match.Success) if (!match.Success)
return match.Groups[1].Value; continue;
string versionText = match.Groups[1].Value;
if (NuGetVersion.TryParse(versionText.TrimStart('v'), out _))
return versionText;
} }
return string.Empty; return string.Empty;
@ -313,6 +355,18 @@ public class Updater : ObservableObject{
} }
} }
public class GithubJson{
[JsonProperty("type")]
public string Type{ get; set; } = string.Empty;
[JsonProperty("version_name")]
public string VersionName{ get; set; } = string.Empty;
[JsonProperty("version_code")]
public string VersionCode{ get; set; } = string.Empty;
[JsonProperty("Authorization")]
public string Authorization{ get; set; } = string.Empty;
}
public class GithubRelease{ public class GithubRelease{
[JsonProperty("tag_name")] [JsonProperty("tag_name")]
public string TagName{ get; set; } = string.Empty; public string TagName{ get; set; } = string.Empty;
@ -370,7 +424,5 @@ public class Updater : ObservableObject{
public bool IsForPlatform(string platform){ public bool IsForPlatform(string platform){
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase); return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
} }
} }
} }

View file

@ -12,11 +12,10 @@ using CRD.Utils.UI;
using CRD.ViewModels.Utils; using CRD.ViewModels.Utils;
using CRD.Views.Utils; using CRD.Views.Utils;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Newtonsoft.Json;
namespace CRD.ViewModels; namespace CRD.ViewModels;
public partial class AccountPageViewModel : ViewModelBase{ public partial class AccountPageViewModel : ViewModelBase, IDisposable{
[ObservableProperty] [ObservableProperty]
private Bitmap? _profileImage; private Bitmap? _profileImage;
@ -32,7 +31,9 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string _remainingTime = ""; private string _remainingTime = "";
private static DispatcherTimer? _timer; private static AccountPageViewModel? _activeInstance;
private readonly DispatcherTimer _timer;
private DateTime _targetTime; private DateTime _targetTime;
private bool IsCancelled; private bool IsCancelled;
@ -40,6 +41,14 @@ public partial class AccountPageViewModel : ViewModelBase{
private bool EndedButMaybeActive; private bool EndedButMaybeActive;
public AccountPageViewModel(){ public AccountPageViewModel(){
_activeInstance?.StopSubscriptionTimer();
_activeInstance = this;
_timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1)
};
_timer.Tick += Timer_Tick;
UpdatetProfile(); UpdatetProfile();
} }
@ -47,7 +56,7 @@ public partial class AccountPageViewModel : ViewModelBase{
var remaining = _targetTime - DateTime.Now; var remaining = _targetTime - DateTime.Now;
if (remaining <= TimeSpan.Zero){ if (remaining <= TimeSpan.Zero){
RemainingTime = "No active Subscription"; RemainingTime = "No active Subscription";
_timer?.Stop(); _timer.Stop();
if (UnknownEndDate){ if (UnknownEndDate){
RemainingTime = "Unknown Subscription end date"; RemainingTime = "Unknown Subscription end date";
} }
@ -55,30 +64,32 @@ public partial class AccountPageViewModel : ViewModelBase{
if (EndedButMaybeActive){ if (EndedButMaybeActive){
RemainingTime = "Subscription maybe ended"; RemainingTime = "Subscription maybe ended";
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
}
} else{ } else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}"; RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
} }
} }
public void UpdatetProfile(){ public void UpdatetProfile(){
StopSubscriptionTimer();
IsCancelled = false;
UnknownEndDate = false;
EndedButMaybeActive = false;
RemainingTime = "No active Subscription";
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1; var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
var firstEndpointProfile = firstEndpoint.Profile; var firstEndpointProfile = firstEndpoint.Profile;
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1; var isLoggedIn = firstEndpointProfile.Username != "???";
HasMultiProfile = isLoggedIn && firstEndpoint.MultiProfile.Profiles.Count > 1;
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state LoginLogoutText = isLoggedIn ? "Logout" : "Login"; // Default state
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar)); (string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription; var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
if (subscriptions != null){ if (subscriptions != null && HasSubscriptionData(subscriptions)){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){ if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
var sub = subscriptions.SubscriptionProducts.First(); var sub = subscriptions.SubscriptionProducts.First();
IsCancelled = sub.IsCancelled; IsCancelled = sub.IsCancelled;
@ -97,23 +108,8 @@ public partial class AccountPageViewModel : ViewModelBase{
if (!UnknownEndDate){ if (!UnknownEndDate){
_targetTime = subscriptions.NextRenewalDate; _targetTime = subscriptions.NextRenewalDate;
_timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1)
};
_timer.Tick += Timer_Tick;
_timer.Start(); _timer.Start();
} Timer_Tick(null, EventArgs.Empty);
} else{
RemainingTime = "No active Subscription";
if (_timer != null){
_timer.Stop();
_timer.Tick -= Timer_Tick;
}
RaisePropertyChanged(nameof(RemainingTime));
if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
} }
} }
@ -126,6 +122,26 @@ public partial class AccountPageViewModel : ViewModelBase{
} }
} }
private static bool HasSubscriptionData(CRD.Utils.Structs.Crunchyroll.Subscription subscriptions){
return subscriptions.SubscriptionProducts is{ Count: > 0 } ||
subscriptions.ThirdPartySubscriptionProducts is{ Count: > 0 } ||
subscriptions.NonrecurringSubscriptionProducts is{ Count: > 0 } ||
subscriptions.FunimationSubscriptions is{ Count: > 0 };
}
private void StopSubscriptionTimer(){
_timer.Stop();
}
public void Dispose(){
_timer.Tick -= Timer_Tick;
StopSubscriptionTimer();
if (ReferenceEquals(_activeInstance, this)){
_activeInstance = null;
}
}
[RelayCommand] [RelayCommand]
public async Task Button_Press(){ public async Task Button_Press(){
if (LoginLogoutText == "Login"){ if (LoginLogoutText == "Login"){

View file

@ -207,14 +207,14 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
if (music != null){ if (music != null){
var meta = musicClass.EpisodeMeta(music); var meta = musicClass.EpisodeMeta(music);
QueueManager.Instance.CrAddMusicMetaToQueue(meta); CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
} }
} }
} else if (AddAllEpisodes){ } else if (AddAllEpisodes){
var musicClass = CrunchyrollManager.Instance.CrMusic; var musicClass = CrunchyrollManager.Instance.CrMusic;
if (currentMusicVideoList == null) return; if (currentMusicVideoList == null) return;
foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){ foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){
QueueManager.Instance.CrAddMusicMetaToQueue(meta); CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
} }
} }
} }
@ -223,7 +223,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
AddItemsToSelectedEpisodes(); AddItemsToSelectedEpisodes();
if (currentSeriesList != null){ if (currentSeriesList != null){
await QueueManager.Instance.CrAddSeriesToQueue( await CrunchyrollManager.Instance.CrQueue.CrAddSeriesToQueue(
currentSeriesList, currentSeriesList,
new CrunchyMultiDownload( new CrunchyMultiDownload(
CrunchyrollManager.Instance.CrunOptions.DubLang, CrunchyrollManager.Instance.CrunOptions.DubLang,
@ -327,17 +327,17 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
} }
private void HandleMusicVideoUrl(string id){ private void HandleMusicVideoUrl(string id){
_ = QueueManager.Instance.CrAddMusicVideoToQueue(id); _ = CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(id);
ResetState(); ResetState();
} }
private void HandleConcertUrl(string id){ private void HandleConcertUrl(string id){
_ = QueueManager.Instance.CrAddConcertToQueue(id); _ = CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(id);
ResetState(); ResetState();
} }
private void HandleEpisodeUrl(string locale, string id){ private void HandleEpisodeUrl(string locale, string id){
_ = QueueManager.Instance.CrAddEpisodeToQueue( _ = CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
id, DetermineLocale(locale), id, DetermineLocale(locale),
CrunchyrollManager.Instance.CrunOptions.DubLang, true); CrunchyrollManager.Instance.CrunOptions.DubLang, true);
ResetState(); ResetState();

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -20,16 +21,16 @@ public partial class DownloadsPageViewModel : ViewModelBase{
public ObservableCollection<DownloadItemModel> Items{ get; } public ObservableCollection<DownloadItemModel> Items{ get; }
[ObservableProperty] [ObservableProperty]
private bool _shutdownWhenQueueEmpty; private bool shutdownWhenQueueEmpty;
[ObservableProperty] [ObservableProperty]
private bool _autoDownload; private bool autoDownload;
[ObservableProperty] [ObservableProperty]
private bool _removeFinished; private bool removeFinished;
[ObservableProperty] [ObservableProperty]
private QueueManager _queueManagerIns; private QueueManager queueManagerIns;
public DownloadsPageViewModel(){ public DownloadsPageViewModel(){
QueueManagerIns = QueueManager.Instance; QueueManagerIns = QueueManager.Instance;
@ -63,10 +64,10 @@ public partial class DownloadsPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public void ClearQueue(){ public void ClearQueue(){
var items = QueueManagerIns.Queue; var items = QueueManagerIns.Queue;
QueueManagerIns.Queue.Clear(); QueueManagerIns.ClearQueue();
foreach (var crunchyEpMeta in items){ foreach (var crunchyEpMeta in items){
if (!crunchyEpMeta.DownloadProgress.Done){ if (!crunchyEpMeta.DownloadProgress.IsDone){
foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){ foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){
try{ try{
if (File.Exists(downloadItemDownloadedFile)){ if (File.Exists(downloadItemDownloadedFile)){
@ -85,13 +86,26 @@ public partial class DownloadsPageViewModel : ViewModelBase{
var items = QueueManagerIns.Queue; var items = QueueManagerIns.Queue;
foreach (var crunchyEpMeta in items){ foreach (var crunchyEpMeta in items){
if (crunchyEpMeta.DownloadProgress.Error){ if (crunchyEpMeta.DownloadProgress.IsError){
crunchyEpMeta.DownloadProgress = new(); crunchyEpMeta.DownloadProgress.ResetForRetry();
} }
} }
QueueManagerIns.UpdateDownloadListItems(); QueueManagerIns.UpdateDownloadListItems();
} }
[RelayCommand]
public void PauseQueue(){
AutoDownload = false;
foreach (var item in Items){
if (item.epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
item.ToggleIsDownloading();
}
}
QueueManagerIns.UpdateDownloadListItems();
}
} }
public partial class DownloadItemModel : INotifyPropertyChanged{ public partial class DownloadItemModel : INotifyPropertyChanged{
@ -113,6 +127,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
public bool Error{ get; set; } public bool Error{ get; set; }
public bool ShowPauseIcon{ get; set; }
public DownloadItemModel(CrunchyEpMeta epMetaF){ public DownloadItemModel(CrunchyEpMeta epMetaF){
epMeta = epMetaF; epMeta = epMetaF;
@ -121,20 +136,14 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
Title = epMeta.SeriesTitle + (!string.IsNullOrEmpty(epMeta.Season) ? " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) : "") + " - " + Title = epMeta.SeriesTitle + (!string.IsNullOrEmpty(epMeta.Season) ? " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) : "") + " - " +
epMeta.EpisodeTitle; epMeta.EpisodeTitle;
isDownloading = epMeta.DownloadProgress.IsDownloading || Done; Done = epMeta.DownloadProgress.IsDone;
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
Done = epMeta.DownloadProgress.Done; ShowPauseIcon = isDownloading;
Percent = epMeta.DownloadProgress.Percent; Percent = epMeta.DownloadProgress.Percent;
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss"); Time = GetTimeText();
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits DownloadSpeed = GetDownloadSpeedText();
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" Paused = epMeta.DownloadProgress.IsPaused;
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s"; DoingWhat = GetDoingWhatText();
;
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
DoingWhat = epMeta.Paused ? "Paused" :
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
InfoText = JoinWithSeparator( InfoText = JoinWithSeparator(
GetDubString(), GetDubString(),
@ -143,7 +152,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
); );
InfoTextHover = epMeta.AvailableQualities; InfoTextHover = epMeta.AvailableQualities;
Error = epMeta.DownloadProgress.Error; Error = epMeta.DownloadProgress.IsError;
} }
string JoinWithSeparator(params string[] parts){ string JoinWithSeparator(params string[] parts){
@ -191,18 +200,15 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
} }
public void Refresh(){ public void Refresh(){
isDownloading = epMeta.DownloadProgress.IsDownloading || Done; Done = epMeta.DownloadProgress.IsDone;
Done = epMeta.DownloadProgress.Done; isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
ShowPauseIcon = isDownloading;
Percent = epMeta.DownloadProgress.Percent; Percent = epMeta.DownloadProgress.Percent;
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss"); Time = GetTimeText();
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits DownloadSpeed = GetDownloadSpeedText();
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; Paused = epMeta.DownloadProgress.IsPaused;
DoingWhat = epMeta.Paused ? "Paused" : DoingWhat = GetDoingWhatText();
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
InfoText = JoinWithSeparator( InfoText = JoinWithSeparator(
GetDubString(), GetDubString(),
@ -210,11 +216,12 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
epMeta.Resolution epMeta.Resolution
); );
InfoTextHover = epMeta.AvailableQualities; InfoTextHover = epMeta.AvailableQualities;
Error = epMeta.DownloadProgress.Error; Error = epMeta.DownloadProgress.IsError;
if (PropertyChanged != null){ if (PropertyChanged != null){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
@ -228,28 +235,90 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
private string GetDoingWhatText(){
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
return "Rate limited, retrying at " + epMeta.DownloadProgress.RetryAtUtc.Value
.ToLocalTime()
.ToString("T", CultureInfo.CurrentCulture);
}
return Paused ? "Paused" :
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
}
private string GetTimeText(){
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
return string.Empty;
}
return "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
}
private string GetDownloadSpeedText(){
if (epMeta.DownloadProgress.IsWaitingForRetry){
return string.Empty;
}
return CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
}
[RelayCommand] [RelayCommand]
public void ToggleIsDownloading(){ public void ToggleIsDownloading(){
if (isDownloading){ if (epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
//StopDownload(); epMeta.DownloadProgress.ResumeState = epMeta.DownloadProgress.State;
epMeta.Paused = !epMeta.Paused; epMeta.DownloadProgress.State = DownloadState.Paused;
isDownloading = false;
Paused = true;
ShowPauseIcon = false;
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
} else{ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
if (epMeta.Paused){
epMeta.Paused = false; QueueManager.Instance.ReleaseDownloadSlot(epMeta);
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; QueueManager.Instance.RefreshQueue();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); return;
} else{
StartDownload();
}
} }
if (epMeta.DownloadProgress.IsPaused){
if (!QueueManager.Instance.TryResumeDownload(epMeta))
return;
if (PropertyChanged != null){ epMeta.DownloadProgress.State = epMeta.DownloadProgress.ResumeState;
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading")); isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
Paused = false;
ShowPauseIcon = isDownloading;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
return;
} }
StartDownload();
}
[RelayCommand]
public void RetryDownload(){
epMeta.DownloadProgress.ResetForRetry();
isDownloading = false;
Paused = false;
ShowPauseIcon = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
QueueManager.Instance.RefreshQueue();
StartDownload();
} }
public Task StartDownload(){ public Task StartDownload(){
@ -261,28 +330,34 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
if (isDownloading) if (isDownloading)
return; return;
epMeta.RenewCancellationToken();
isDownloading = true; isDownloading = true;
epMeta.DownloadProgress.IsDownloading = true; epMeta.DownloadProgress.State = DownloadState.Downloading;
Paused = false;
ShowPauseIcon = true;
Paused = epMeta.Paused; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions); CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (epMeta.OnlySubs){ if (epMeta.OnlySubs){
newOptions.Novids = true; newOptions?.Novids = true;
newOptions.Noaudio = true; newOptions?.Noaudio = true;
} }
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions); await CrunchyrollManager.Instance.DownloadEpisode(
epMeta,
epMeta.DownloadSettings ?? newOptions ?? CrunchyrollManager.Instance.CrunOptions);
} }
[RelayCommand] [RelayCommand]
public void RemoveFromQueue(){ public void RemoveFromQueue(){
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null; CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
if (downloadItem != null){ if (downloadItem != null){
QueueManager.Instance.Queue.Remove(downloadItem); QueueManager.Instance.RemoveFromQueue(downloadItem);
epMeta.Cts.Cancel(); epMeta.CancelDownload();
if (!Done){ if (!Done){
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){ foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
try{ try{

View file

@ -87,6 +87,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _showArtists; private bool _showArtists;
[ObservableProperty]
private bool _showMovies = true;
[ObservableProperty] [ObservableProperty]
private static bool _viewSelectionOpen; private static bool _viewSelectionOpen;
@ -160,6 +163,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
SortDir = properties?.Ascending ?? false; SortDir = properties?.Ascending ?? false;
ShowSeries = properties?.ShowSeries ?? true; ShowSeries = properties?.ShowSeries ?? true;
ShowArtists = properties?.ShowArtists ?? false; ShowArtists = properties?.ShowArtists ?? false;
ShowMovies = properties?.ShowMovies ?? true;
foreach (HistoryViewType viewType in Enum.GetValues(typeof(HistoryViewType))){ foreach (HistoryViewType viewType in Enum.GetValues(typeof(HistoryViewType))){
var combobox = new ComboBoxItem{ Content = viewType }; var combobox = new ComboBoxItem{ Content = viewType };
@ -274,6 +278,14 @@ public partial class HistoryPageViewModel : ViewModelBase{
ApplyFilter(); ApplyFilter();
} }
partial void OnShowMoviesChanged(bool value){
if (CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties != null) CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties.ShowMovies = ShowMovies;
CfgManager.WriteCrSettings();
ApplyFilter();
}
partial void OnSelectedFilterChanged(FilterListElement? value){ partial void OnSelectedFilterChanged(FilterListElement? value){
if (value == null){ if (value == null){
@ -333,6 +345,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series); filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
} }
if (!ShowMovies){
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Movie);
}
if (!string.IsNullOrWhiteSpace(SearchInput)){ if (!string.IsNullOrWhiteSpace(SearchInput)){
var tokens = SearchInput var tokens = SearchInput
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
@ -662,6 +678,7 @@ public class HistoryPageProperties{
public bool ShowSeries{ get; set; } = true; public bool ShowSeries{ get; set; } = true;
public bool ShowArtists{ get; set; } = true; public bool ShowArtists{ get; set; } = true;
public bool ShowMovies{ get; set; } = true;
} }
public class SeasonsPageProperties{ public class SeasonsPageProperties{

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
@ -7,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CRD.Utils.Ffmpeg_Encoding; using CRD.Utils.Ffmpeg_Encoding;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Views; using CRD.Views;
using DynamicData; using DynamicData;
@ -19,61 +21,84 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
private readonly ContentDialog dialog; private readonly ContentDialog dialog;
[ObservableProperty] [ObservableProperty]
private bool _editMode; private bool editMode;
[ObservableProperty] [ObservableProperty]
private string _presetName; [NotifyPropertyChangedFor(nameof(CommandPreview))]
private string presetName;
[ObservableProperty] [ObservableProperty]
private string _codec; [NotifyPropertyChangedFor(nameof(HasCodec))]
[NotifyPropertyChangedFor(nameof(CommandPreview))]
private string codec;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem _selectedResolution = new(); [NotifyPropertyChangedFor(nameof(CommandPreview))]
private StringItemWithDisplayName selectedResolution = new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" };
[ObservableProperty] [ObservableProperty]
private double? _crf = 23; [NotifyPropertyChangedFor(nameof(CommandPreview))]
private double? crf = 23;
[ObservableProperty] [ObservableProperty]
private string _frameRate = ""; [NotifyPropertyChangedFor(nameof(CommandPreview))]
private string frameRate = "";
[ObservableProperty] [ObservableProperty]
private string _additionalParametersString = ""; [NotifyPropertyChangedFor(nameof(CommandPreview))]
private string additionalParametersString = "";
[ObservableProperty] [ObservableProperty]
private ObservableCollection<StringItem> _additionalParameters = new(); [NotifyPropertyChangedFor(nameof(CommandPreview))]
private ObservableCollection<StringItem> additionalParameters = new();
[ObservableProperty] [ObservableProperty]
private VideoPreset? _selectedCustomPreset; private VideoPreset? selectedCustomPreset;
[ObservableProperty] [ObservableProperty]
private bool _fileExists; private bool fileExists;
public bool HasCodec => !string.IsNullOrWhiteSpace(Codec);
public string CommandPreview => BuildCommandPreview();
public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ }; public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
public ObservableCollection<ComboBoxItem> ResolutionList{ get; } = new(){ public ObservableCollection<StringItemWithDisplayName> ResolutionList{ get; } = new(){
new ComboBoxItem(){ Content = "3840:2160" }, // 4K UHD new(){ value = "3840:2160", DisplayName = "4K exact (3840:2160)" },
new ComboBoxItem(){ Content = "3440:1440" }, // Ultra-Wide Quad HD new(){ value = "-2:2160", DisplayName = "4K keep AR (-2:2160)" },
new ComboBoxItem(){ Content = "2560:1440" }, // 1440p new(){ value = "3440:1440", DisplayName = "UWQHD exact (3440:1440)" },
new ComboBoxItem(){ Content = "2560:1080" }, // Ultra-Wide Full HD new(){ value = "2560:1440", DisplayName = "1440p exact (2560:1440)" },
new ComboBoxItem(){ Content = "2160:1080" }, // 2:1 Aspect Ratio new(){ value = "-2:1440", DisplayName = "1440p keep AR (-2:1440)" },
new ComboBoxItem(){ Content = "1920:1080" }, // 1080p Full HD new(){ value = "2560:1080", DisplayName = "UW FHD exact (2560:1080)" },
new ComboBoxItem(){ Content = "1920:800" }, // Cinematic 2.40:1 new(){ value = "2160:1080", DisplayName = "2:1 exact (2160:1080)" },
new ComboBoxItem(){ Content = "1600:900" }, // 900p new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" },
new ComboBoxItem(){ Content = "1366:768" }, // 768p new(){ value = "-2:1080", DisplayName = "1080p keep AR (-2:1080)" },
new ComboBoxItem(){ Content = "1280:960" }, // SXGA 4:3 new(){ value = "1920:800", DisplayName = "Cinema exact (1920:800)" },
new ComboBoxItem(){ Content = "1280:720" }, // 720p HD new(){ value = "1600:900", DisplayName = "900p exact (1600:900)" },
new ComboBoxItem(){ Content = "1024:576" }, // 576p new(){ value = "1366:768", DisplayName = "768p exact (1366:768)" },
new ComboBoxItem(){ Content = "960:540" }, // 540p qHD new(){ value = "1280:960", DisplayName = "SXGA exact (1280:960)" },
new ComboBoxItem(){ Content = "854:480" }, // 480p new(){ value = "1280:720", DisplayName = "720p exact (1280:720)" },
new ComboBoxItem(){ Content = "800:600" }, // SVGA new(){ value = "-2:720", DisplayName = "720p keep AR (-2:720)" },
new ComboBoxItem(){ Content = "768:432" }, // 432p new(){ value = "1024:576", DisplayName = "576p exact (1024:576)" },
new ComboBoxItem(){ Content = "720:480" }, // NTSC SD new(){ value = "-2:576", DisplayName = "576p keep AR (-2:576)" },
new ComboBoxItem(){ Content = "704:576" }, // PAL SD new(){ value = "960:540", DisplayName = "540p exact (960:540)" },
new ComboBoxItem(){ Content = "640:360" }, // 360p new(){ value = "-2:540", DisplayName = "540p keep AR (-2:540)" },
new ComboBoxItem(){ Content = "426:240" }, // 240p new(){ value = "854:480", DisplayName = "480p exact (854:480)" },
new ComboBoxItem(){ Content = "320:240" }, // QVGA new(){ value = "-2:480", DisplayName = "480p keep AR (-2:480)" },
new ComboBoxItem(){ Content = "320:180" }, // 180p new(){ value = "800:600", DisplayName = "SVGA exact (800:600)" },
new ComboBoxItem(){ Content = "256:144" }, // 144p new(){ value = "768:432", DisplayName = "432p exact (768:432)" },
new(){ value = "-2:432", DisplayName = "432p keep AR (-2:432)" },
new(){ value = "720:480", DisplayName = "NTSC exact (720:480)" },
new(){ value = "704:576", DisplayName = "PAL exact (704:576)" },
new(){ value = "640:360", DisplayName = "360p exact (640:360)" },
new(){ value = "-2:360", DisplayName = "360p keep AR (-2:360)" },
new(){ value = "426:240", DisplayName = "240p exact (426:240)" },
new(){ value = "-2:240", DisplayName = "240p keep AR (-2:240)" },
new(){ value = "320:240", DisplayName = "QVGA exact (320:240)" },
new(){ value = "320:180", DisplayName = "180p exact (320:180)" },
new(){ value = "-2:180", DisplayName = "180p keep AR (-2:180)" },
new(){ value = "256:144", DisplayName = "144p exact (256:144)" },
new(){ value = "-2:144", DisplayName = "144p keep AR (-2:144)" },
}; };
public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){ public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){
@ -84,6 +109,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
} }
AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" }); AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" });
AdditionalParameters.CollectionChanged += AdditionalParametersOnCollectionChanged;
if (editMode){ if (editMode){
EditMode = true; EditMode = true;
@ -108,9 +134,9 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
PresetName = value.PresetName ?? ""; PresetName = value.PresetName ?? "";
Codec = value.Codec ?? ""; Codec = value.Codec ?? "";
Crf = value.Crf; Crf = value.Crf;
FrameRate = value.FrameRate ?? "24"; FrameRate = value.FrameRate ?? "24000/1001";
SelectedResolution = ResolutionList.FirstOrDefault(e => e.Content?.ToString() == value.Resolution) ?? ResolutionList.First(); SelectedResolution = ResolutionList.FirstOrDefault(e => e.value == value.Resolution) ?? ResolutionList.First();
AdditionalParameters.Clear(); AdditionalParameters.Clear();
foreach (var valueAdditionalParameter in value.AdditionalParameters){ foreach (var valueAdditionalParameter in value.AdditionalParameters){
@ -133,13 +159,36 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
public void AddAdditionalParam(){ public void AddAdditionalParam(){
AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString }); AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString });
AdditionalParametersString = ""; AdditionalParametersString = "";
RaisePropertyChanged(nameof(AdditionalParametersString));
} }
[RelayCommand] [RelayCommand]
public void RemoveAdditionalParam(StringItem param){ public void RemoveAdditionalParam(StringItem param){
AdditionalParameters.Remove(param); AdditionalParameters.Remove(param);
RaisePropertyChanged(nameof(AdditionalParameters)); }
private void AdditionalParametersOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e){
OnPropertyChanged(nameof(CommandPreview));
}
private string BuildCommandPreview(){
var previewPreset = new VideoPreset{
PresetName = PresetName,
Codec = Codec,
FrameRate = string.IsNullOrWhiteSpace(FrameRate) ? "24000/1001" : FrameRate,
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
Resolution = SelectedResolution.value,
AdditionalParameters = AdditionalParameters
.Select(additionalParameter => additionalParameter.stringValue)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.ToList()
};
var args = Helpers.BuildFFmpegArgsForPreset(
"S01E01.mkv",
previewPreset,
"S01E01_output.mkv");
return Helpers.BuildCommandString("ffmpeg", args);
} }
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){ private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
@ -153,7 +202,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
SelectedCustomPreset.Codec = Codec; SelectedCustomPreset.Codec = Codec;
SelectedCustomPreset.FrameRate = FrameRate; SelectedCustomPreset.FrameRate = FrameRate;
SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51); SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51);
SelectedCustomPreset.Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080"; SelectedCustomPreset.Resolution = SelectedResolution.value;
SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList(); SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList();
try{ try{
@ -175,7 +224,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
Codec = Codec, Codec = Codec,
FrameRate = FrameRate, FrameRate = FrameRate,
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51), Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080", Resolution = SelectedResolution.value,
AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList() AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList()
}; };
@ -186,6 +235,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
} }
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){ private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
AdditionalParameters.CollectionChanged -= AdditionalParametersOnCollectionChanged;
dialog.Closed -= DialogOnClosed; dialog.Closed -= DialogOnClosed;
} }
} }

View file

@ -20,15 +20,20 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http; using CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views;
using FluentAvalonia.Styling; using FluentAvalonia.Styling;
using ReactiveUI;
namespace CRD.ViewModels.Utils; namespace CRD.ViewModels.Utils;
public partial class GeneralSettingsViewModel : ViewModelBase{ public partial class GeneralSettingsViewModel : ViewModelBase{
private readonly AudioPlayer notificationTestPlayer = new();
[ObservableProperty] [ObservableProperty]
private string currentVersion; private string currentVersion;
@ -44,6 +49,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool historyIncludeCrArtists; private bool historyIncludeCrArtists;
[ObservableProperty]
private bool historyRemoveMissingEpisodes;
[ObservableProperty] [ObservableProperty]
private bool historyAddSpecials; private bool historyAddSpecials;
@ -59,6 +67,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private HistoryRefreshMode historyAutoRefreshMode; private HistoryRefreshMode historyAutoRefreshMode;
[ObservableProperty]
private bool historyAutoRefreshAddToQueue;
[ObservableProperty] [ObservableProperty]
private string historyAutoRefreshModeHint; private string historyAutoRefreshModeHint;
@ -86,6 +97,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool downloadAllowEarlyStart; private bool downloadAllowEarlyStart;
[ObservableProperty]
private bool persistQueue;
[ObservableProperty] [ObservableProperty]
private double? downloadSpeed; private double? downloadSpeed;
@ -98,6 +112,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private double? retryDelay; private double? retryDelay;
[ObservableProperty]
private double? playbackRateLimitRetryDelaySeconds;
[ObservableProperty]
private double? retryMaxDelaySeconds;
[ObservableProperty] [ObservableProperty]
private bool trayIconEnabled; private bool trayIconEnabled;
@ -285,9 +305,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string downloadFinishedExecutePath; private string downloadFinishedExecutePath;
[ObservableProperty]
private bool webhookEnabled;
[ObservableProperty]
private string webhookUrl = string.Empty;
[ObservableProperty]
private string webhookMethod = "POST";
[ObservableProperty]
private string webhookContentType = "application/json";
[ObservableProperty]
private string webhookHeadersText = string.Empty;
[ObservableProperty]
private string webhookBodyTemplate = string.Empty;
[ObservableProperty]
private bool webhookNotifyQueueFinished;
[ObservableProperty]
private bool webhookNotifyDownloadFinished;
[ObservableProperty]
private bool webhookNotifyDownloadFailed;
[ObservableProperty]
private bool webhookNotifyTrackedSeriesEpisodeReleased;
[ObservableProperty]
private bool webhookNotifyLoginExpired;
[ObservableProperty]
private bool webhookNotifyUpdateAvailable;
[ObservableProperty] [ObservableProperty]
private string currentIp = ""; private string currentIp = "";
[ObservableProperty]
private bool isTestingFinishedSound;
[ObservableProperty]
private bool isTestingWebhook;
private readonly FluentAvaloniaTheme faTheme; private readonly FluentAvaloniaTheme faTheme;
private bool settingsLoaded; private bool settingsLoaded;
@ -312,16 +374,29 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions; CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions;
options.NormalizeNotificationSettings();
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius; BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
BackgroundImageOpacity = options.BackgroundImageOpacity; BackgroundImageOpacity = options.BackgroundImageOpacity;
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty; BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty; var soundProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound);
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound; DownloadFinishedSoundPath = soundProvider?.Path ?? string.Empty;
DownloadFinishedPlaySound = soundProvider?.Enabled ?? false;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty; var executeProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute);
DownloadFinishedExecute = options.DownloadFinishedExecute; DownloadFinishedExecutePath = executeProvider?.Path ?? string.Empty;
DownloadFinishedExecute = executeProvider?.Enabled ?? false;
var webhookProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Webhook);
WebhookEnabled = webhookProvider?.Enabled ?? false;
WebhookUrl = webhookProvider?.Url ?? string.Empty;
WebhookMethod = string.IsNullOrWhiteSpace(webhookProvider?.Method) ? "POST" : webhookProvider.Method;
WebhookContentType = string.IsNullOrWhiteSpace(webhookProvider?.ContentType) ? "application/json" : webhookProvider.ContentType;
WebhookHeadersText = SerializeHeaders(webhookProvider?.Headers);
WebhookBodyTemplate = webhookProvider?.BodyTemplate ?? string.Empty;
LoadProviderEvents(webhookProvider, NotificationProviderType.Webhook);
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath; DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath; TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -365,19 +440,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
ProxyPort = options.ProxyPort; ProxyPort = options.ProxyPort;
HistoryCountMissing = options.HistoryCountMissing; HistoryCountMissing = options.HistoryCountMissing;
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists; HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
HistoryRemoveMissingEpisodes = options.HistoryRemoveMissingEpisodes;
HistoryAddSpecials = options.HistoryAddSpecials; HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored; HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr; HistoryCountSonarr = options.HistoryCountSonarr;
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes; HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode; HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
HistoryAutoRefreshAddToQueue = options.HistoryAutoRefreshAddToQueue;
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture); HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
DownloadSpeed = options.DownloadSpeedLimit; DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits; DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew; DownloadMethodeNew = options.DownloadMethodeNew;
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart; DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub; DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
PersistQueue = options.PersistQueue;
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10); RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30); RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
PlaybackRateLimitRetryDelaySeconds = Math.Clamp(options.PlaybackRateLimitRetryDelaySeconds, 1, 86400);
RetryMaxDelaySeconds = Math.Clamp(options.RetryMaxDelaySeconds, 1, 86400);
DownloadToTempFolder = options.DownloadToTempFolder; DownloadToTempFolder = options.DownloadToTempFolder;
SimultaneousDownloads = options.SimultaneousDownloads; SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs; SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
@ -417,34 +497,65 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var settings = CrunchyrollManager.Instance.CrunOptions; var settings = CrunchyrollManager.Instance.CrunOptions;
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound; settings.NotificationSettings ??= new NotificationSettings();
settings.DownloadFinishedExecute = DownloadFinishedExecute; var soundProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
soundProvider.Enabled = DownloadFinishedPlaySound;
soundProvider.Path = DownloadFinishedSoundPath;
soundProvider.Events = [NotificationEventType.QueueFinished];
var executeProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
executeProvider.Enabled = DownloadFinishedExecute;
executeProvider.Path = DownloadFinishedExecutePath;
executeProvider.Events = [NotificationEventType.QueueFinished];
var webhookProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Webhook);
webhookProvider.Enabled = WebhookEnabled;
webhookProvider.Url = WebhookUrl?.Trim() ?? string.Empty;
webhookProvider.Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant();
webhookProvider.ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim();
webhookProvider.Headers = ParseHeaders(WebhookHeadersText);
webhookProvider.BodyTemplate = WebhookBodyTemplate ?? string.Empty;
webhookProvider.Events = BuildEvents(
WebhookNotifyQueueFinished,
WebhookNotifyDownloadFinished,
WebhookNotifyDownloadFailed,
WebhookNotifyTrackedSeriesEpisodeReleased,
WebhookNotifyLoginExpired,
WebhookNotifyUpdateAvailable
);
settings.SyncLegacyNotificationFields();
settings.DownloadMethodeNew = DownloadMethodeNew; settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart; settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub; settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
settings.PersistQueue = PersistQueue;
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40); settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1); settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10); settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10);
settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30); settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30);
settings.PlaybackRateLimitRetryDelaySeconds = Math.Clamp((int)(PlaybackRateLimitRetryDelaySeconds ?? 0), 1, 86400);
settings.RetryMaxDelaySeconds = Math.Clamp((int)(RetryMaxDelaySeconds ?? 0), 1, 86400);
settings.DownloadToTempFolder = DownloadToTempFolder; settings.DownloadToTempFolder = DownloadToTempFolder;
settings.HistoryCountMissing = HistoryCountMissing; settings.HistoryCountMissing = HistoryCountMissing;
settings.HistoryAddSpecials = HistoryAddSpecials; settings.HistoryAddSpecials = HistoryAddSpecials;
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists; settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistoryRemoveMissingEpisodes = HistoryRemoveMissingEpisodes;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored; settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr; settings.HistoryCountSonarr = HistoryCountSonarr;
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ; settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode; settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
settings.HistoryAutoRefreshAddToQueue = HistoryAutoRefreshAddToQueue;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000); settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits; settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10); settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10); settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs); QueueManager.Instance.SetProcessingLimit(settings.SimultaneousProcessingJobs);
settings.ProxyEnabled = ProxyEnabled; settings.ProxyEnabled = ProxyEnabled;
settings.ProxySocks = ProxySocks; settings.ProxySocks = ProxySocks;
@ -519,6 +630,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.LogMode = LogMode; settings.LogMode = LogMode;
CfgManager.WriteCrSettings(); CfgManager.WriteCrSettings();
if (!PersistQueue){
QueueManager.Instance.SaveQueueSnapshot();
}
} }
[RelayCommand] [RelayCommand]
@ -613,7 +728,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public void ClearFinishedSoundPath(){ public void ClearFinishedSoundPath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = string.Empty; var settings = CrunchyrollManager.Instance.CrunOptions;
settings.NotificationSettings ??= new NotificationSettings();
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = string.Empty;
settings.SyncLegacyNotificationFields();
DownloadFinishedSoundPath = string.Empty; DownloadFinishedSoundPath = string.Empty;
} }
@ -627,21 +745,131 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
}, },
pathSetter: (path) => { pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path; var validationResult = AudioPlayer.ValidateSoundFile(path);
if (!validationResult.IsValid){
MessageBus.Current.SendMessage(new ToastMessage(validationResult.ErrorMessage, ToastType.Error, 5));
return;
}
var settings = CrunchyrollManager.Instance.CrunOptions;
settings.NotificationSettings ??= new NotificationSettings();
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = path;
settings.SyncLegacyNotificationFields();
DownloadFinishedSoundPath = path; DownloadFinishedSoundPath = path;
MessageBus.Current.SendMessage(new ToastMessage("Notification sound updated", ToastType.Information, 2));
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty,
defaultPath: string.Empty defaultPath: string.Empty
); );
} }
[RelayCommand]
public async Task TestFinishedSoundAsync(){
if (IsTestingFinishedSound){
return;
}
var path = CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty;
IsTestingFinishedSound = true;
try{
var result = await notificationTestPlayer.ValidatePlaybackAsync(path);
if (result.IsSuccess){
MessageBus.Current.SendMessage(new ToastMessage("Notification sound test succeeded", ToastType.Information, 2));
return;
}
MessageBus.Current.SendMessage(new ToastMessage($"Notification sound test failed: {result.ErrorMessage}", ToastType.Error, 5));
} finally{
IsTestingFinishedSound = false;
}
}
[RelayCommand]
public async Task StopFinishedSoundAsync(){
await notificationTestPlayer.StopAsync();
IsTestingFinishedSound = false;
}
[RelayCommand]
public async Task TestWebhookAsync(){
if (IsTestingWebhook){
return;
}
var selectedEvents = BuildEvents(
WebhookNotifyQueueFinished,
WebhookNotifyDownloadFinished,
WebhookNotifyDownloadFailed,
WebhookNotifyTrackedSeriesEpisodeReleased,
WebhookNotifyLoginExpired,
WebhookNotifyUpdateAvailable
);
if (!WebhookEnabled){
MessageBus.Current.SendMessage(new ToastMessage("Enable the webhook first", ToastType.Error, 4));
return;
}
if (string.IsNullOrWhiteSpace(WebhookUrl)){
MessageBus.Current.SendMessage(new ToastMessage("Set a webhook URL first", ToastType.Error, 4));
return;
}
if (selectedEvents.Count == 0){
MessageBus.Current.SendMessage(new ToastMessage("Select at least one webhook event to test", ToastType.Error, 4));
return;
}
IsTestingWebhook = true;
try{
var settings = new NotificationSettings{
Providers = [
new NotificationProviderConfig{
Type = NotificationProviderType.Webhook,
Enabled = true,
Url = WebhookUrl.Trim(),
Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant(),
ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim(),
Headers = ParseHeaders(WebhookHeadersText),
BodyTemplate = WebhookBodyTemplate ?? string.Empty,
Events = selectedEvents
}
]
};
var sentCount = 0;
foreach (var notificationEvent in BuildTestWebhookEvents(selectedEvents)){
if (await NotificationDispatcher.Instance.PublishWithResultAsync(settings, notificationEvent)){
sentCount++;
}
}
if (sentCount == selectedEvents.Count){
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} test webhook event(s)", ToastType.Information, 3));
} else if (sentCount > 0){
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} of {selectedEvents.Count} test webhook event(s)", ToastType.Error, 5));
} else{
MessageBus.Current.SendMessage(new ToastMessage("Webhook test failed for all selected events", ToastType.Error, 5));
}
} finally{
IsTestingWebhook = false;
}
}
#endregion #endregion
#region Download Finished Execute File #region Download Finished Execute File
[RelayCommand] [RelayCommand]
public void ClearFinishedExectuePath(){ public void ClearFinishedExectuePath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty; var settings = CrunchyrollManager.Instance.CrunOptions;
settings.NotificationSettings ??= new NotificationSettings();
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = string.Empty;
settings.SyncLegacyNotificationFields();
DownloadFinishedExecutePath = string.Empty; DownloadFinishedExecutePath = string.Empty;
} }
@ -655,10 +883,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
}, },
pathSetter: (path) => { pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path; var settings = CrunchyrollManager.Instance.CrunOptions;
settings.NotificationSettings ??= new NotificationSettings();
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = path;
settings.SyncLegacyNotificationFields();
DownloadFinishedExecutePath = path; DownloadFinishedExecutePath = path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute).Path ?? string.Empty,
defaultPath: string.Empty defaultPath: string.Empty
); );
} }
@ -692,6 +923,189 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
} }
private void LoadProviderEvents(NotificationProviderConfig? provider, NotificationProviderType type){
var events = provider?.Events ?? [];
switch (type){
case NotificationProviderType.Webhook:
WebhookNotifyQueueFinished = events.Contains(NotificationEventType.QueueFinished);
WebhookNotifyDownloadFinished = events.Contains(NotificationEventType.DownloadFinished);
WebhookNotifyDownloadFailed = events.Contains(NotificationEventType.DownloadFailed);
WebhookNotifyTrackedSeriesEpisodeReleased = events.Contains(NotificationEventType.TrackedSeriesEpisodeReleased);
WebhookNotifyLoginExpired = events.Contains(NotificationEventType.LoginExpired);
WebhookNotifyUpdateAvailable = events.Contains(NotificationEventType.UpdateAvailable);
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
}
private static List<NotificationEventType> BuildEvents(
bool queueFinished,
bool downloadFinished,
bool downloadFailed,
bool trackedSeriesEpisodeReleased,
bool loginExpired,
bool updateAvailable){
var events = new List<NotificationEventType>();
if (queueFinished){
events.Add(NotificationEventType.QueueFinished);
}
if (downloadFinished){
events.Add(NotificationEventType.DownloadFinished);
}
if (downloadFailed){
events.Add(NotificationEventType.DownloadFailed);
}
if (trackedSeriesEpisodeReleased){
events.Add(NotificationEventType.TrackedSeriesEpisodeReleased);
}
if (loginExpired){
events.Add(NotificationEventType.LoginExpired);
}
if (updateAvailable){
events.Add(NotificationEventType.UpdateAvailable);
}
return events;
}
private static IEnumerable<NotificationEvent> BuildTestWebhookEvents(IEnumerable<NotificationEventType> selectedEvents){
foreach (var eventType in selectedEvents.Distinct()){
yield return BuildTestWebhookEvent(eventType);
}
}
private static NotificationEvent BuildTestWebhookEvent(NotificationEventType eventType){
return eventType switch{
NotificationEventType.QueueFinished => new NotificationEvent{
Type = NotificationEventType.QueueFinished,
Title = "Downloads finished",
Message = "All queued downloads have finished processing.",
Metadata = []
},
NotificationEventType.DownloadFinished => new NotificationEvent{
Type = NotificationEventType.DownloadFinished,
Title = "Download finished",
Message = "Finished processing Example Series.",
Metadata = BuildTestDownloadMetadata()
},
NotificationEventType.DownloadFailed => new NotificationEvent{
Type = NotificationEventType.DownloadFailed,
Title = "Download failed",
Message = "Failed to download Example Series: Example failure message",
Metadata = BuildTestDownloadMetadata("Example failure message")
},
NotificationEventType.TrackedSeriesEpisodeReleased => new NotificationEvent{
Type = NotificationEventType.TrackedSeriesEpisodeReleased,
Title = "Tracked series episode released",
Message = "A tracked episode is available for Example Series: Episode Title.",
Metadata = new Dictionary<string, string>{
["seriesTitle"] = "Example Series",
["seriesId"] = "G6ABC1234",
["seasonId"] = "G6SEASON01",
["episodeTitle"] = "Episode Title",
["episodeId"] = "G6EP0001",
["episodeNumber"] = "1",
["seasonNumber"] = "1",
["releaseDate"] = DateTimeOffset.UtcNow.AddMinutes(-30).ToString("O"),
["premiumAvailableDate"] = DateTimeOffset.UtcNow.ToString("O"),
["episodeUrl"] = "https://www.crunchyroll.com/en-US/watch/G6EP0001/episode-title",
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
["description"] = "Example tracked-release description.",
["durationMs"] = "1440000",
["availableDubs"] = "en-US, ja-JP",
["availableSubs"] = "en-US, de-DE"
}
},
NotificationEventType.LoginExpired => new NotificationEvent{
Type = NotificationEventType.LoginExpired,
Title = "Crunchyroll login expired",
Message = "The saved Crunchyroll session could not be refreshed. Please log in again.",
Metadata = new Dictionary<string, string>{
["username"] = "example-user",
["endpoint"] = "/auth/v1/token"
}
},
NotificationEventType.UpdateAvailable => new NotificationEvent{
Type = NotificationEventType.UpdateAvailable,
Title = "Update available",
Message = "Version v9.9.9 is available. Current version: v1.0.0.",
Metadata = new Dictionary<string, string>{
["currentVersion"] = "v1.0.0",
["latestVersion"] = "v9.9.9",
["platform"] = "win-x64",
["downloadUrl"] = "https://github.com/Crunchy-DL/Crunchy-Downloader/releases/latest"
}
},
_ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null)
};
}
private static Dictionary<string, string> BuildTestDownloadMetadata(string? error = null){
var metadata = new Dictionary<string, string>{
["seriesTitle"] = "Example Series",
["seasonTitle"] = "Season 1",
["episodeTitle"] = "Episode Title",
["episodeNumber"] = "1",
["episodeId"] = "G6EP0001",
["downloadPath"] = @"C:\Downloads\Example Series\Season 1",
["seasonNumber"] = "1",
["description"] = "Example download description.",
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
["imageUrlLarge"] = "https://static.crunchyroll.com/example-poster.jpg",
["downloadSubs"] = "en-US, de-DE",
["downloadDubs"] = "ja-JP",
["hardsub"] = string.Empty,
["seriesId"] = "G6ABC1234",
["seasonId"] = "G6SEASON01",
["episodeUrl"] = "https://www.crunchyroll.com/watch/G6EP0001"
};
if (!string.IsNullOrWhiteSpace(error)){
metadata["error"] = error;
}
return metadata;
}
private static string SerializeHeaders(IReadOnlyDictionary<string, string>? headers){
if (headers == null || headers.Count == 0){
return string.Empty;
}
return string.Join(Environment.NewLine, headers.Select(pair => $"{pair.Key}: {pair.Value}"));
}
private static Dictionary<string, string> ParseHeaders(string? headerText){
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(headerText)){
return headers;
}
foreach (var rawLine in headerText.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)){
var separatorIndex = rawLine.IndexOf(':');
if (separatorIndex <= 0){
continue;
}
var key = rawLine[..separatorIndex].Trim();
var value = rawLine[(separatorIndex + 1)..].Trim();
if (!string.IsNullOrWhiteSpace(key)){
headers[key] = value;
}
}
return headers;
}
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){ partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
if (value?.Content?.ToString() == "System"){ if (value?.Content?.ToString() == "System"){
@ -758,7 +1172,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(CurrentAppTheme) or nameof(CurrentAppTheme)
or nameof(UseCustomAccent) or nameof(UseCustomAccent)
or nameof(TrayIconEnabled) or nameof(TrayIconEnabled)
or nameof(LogMode)){ or nameof(LogMode)
or nameof(PersistQueue)){
return; return;
} }
@ -829,4 +1244,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CfgManager.DisableLogMode(); CfgManager.DisableLogMode();
} }
} }
partial void OnPersistQueueChanged(bool value){
UpdateSettings();
QueueManager.Instance.SaveQueueSnapshot();
}
} }

View file

@ -43,6 +43,20 @@
</ToolTip.Tip> </ToolTip.Tip>
</Button> </Button>
<Button BorderThickness="0"
HorizontalAlignment="Right"
Margin="0 0 10 0 "
VerticalAlignment="Center"
IsEnabled="{Binding QueueManagerIns.HasActiveDownloads}"
Command="{Binding PauseQueue}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<controls:SymbolIcon Symbol="Pause" FontSize="22" />
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Pause running" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
</ToolTip.Tip>
</Button>
<Button BorderThickness="0" <Button BorderThickness="0"
HorizontalAlignment="Right" HorizontalAlignment="Right"
Margin="0 0 10 0 " Margin="0 0 10 0 "
@ -108,11 +122,11 @@
HorizontalAlignment="Right" VerticalAlignment="Top"> HorizontalAlignment="Right" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="{Binding <controls:SymbolIcon Symbol="{Binding
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" /> ShowPauseIcon, Converter={StaticResource UiValueConverter}}" FontSize="18" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic" <Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding RetryDownload}" FontStyle="Italic"
HorizontalAlignment="Right" VerticalAlignment="Top"> HorizontalAlignment="Right" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Refresh" FontSize="18" /> <controls:SymbolIcon Symbol="Refresh" FontSize="18" />

View file

@ -257,6 +257,16 @@
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" /> <ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" />
</Grid> </Grid>
<Grid Margin="8 0 5 0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Movies" Grid.Column="0" VerticalAlignment="Center" />
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowMovies}" VerticalAlignment="Center" />
</Grid>
<Rectangle Height="1" Fill="Gray" Margin="0,8,0,8" /> <Rectangle Height="1" Fill="Gray" Margin="0,8,0,8" />

View file

@ -38,29 +38,43 @@
<StackPanel> <StackPanel>
<TextBlock Text="Enter Codec" Margin="0,10,0,5" /> <TextBlock Text="Enter Codec" Margin="0,10,0,5" />
<TextBox Watermark="libx265" Text="{Binding Codec}" /> <TextBox Watermark="libx265" Text="{Binding Codec}" />
<TextBlock Text="Leave empty to provide the encoding options through Additional Parameters only."
Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
IsVisible="{Binding !HasCodec}" />
</StackPanel> </StackPanel>
<!-- Resolution ComboBox --> <!-- Resolution ComboBox -->
<StackPanel> <StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Select Resolution" Margin="0,10,0,5" /> <TextBlock Text="Select Resolution" Margin="0,10,0,5" />
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400" <ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding ResolutionList}" ItemsSource="{Binding ResolutionList}"
SelectedItem="{Binding SelectedResolution}"> SelectedItem="{Binding SelectedResolution}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<TextBlock Text="Use exact sizes for fixed output dimensions, or keep AR to preserve the source aspect ratio."
Opacity="0.7"
FontSize="12"
TextWrapping="Wrap" />
</StackPanel> </StackPanel>
<!-- Frame Rate NumberBox --> <!-- Frame Rate NumberBox -->
<StackPanel> <StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" /> <TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" />
<!-- <controls:NumberBox Minimum="1" Maximum="999" --> <!-- <controls:NumberBox Minimum="1" Maximum="999" -->
<!-- Value="{Binding FrameRate}" --> <!-- Value="{Binding FrameRate}" -->
<!-- SpinButtonPlacementMode="Inline" --> <!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" /> --> <!-- HorizontalAlignment="Stretch" /> -->
<TextBox Watermark="24" Text="{Binding FrameRate}" /> <TextBox Watermark="24000/1001" Text="{Binding FrameRate}" />
</StackPanel> </StackPanel>
<!-- CRF NumberBox --> <!-- CRF NumberBox -->
<StackPanel> <StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" /> <TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
<controls:NumberBox Minimum="0" Maximum="51" <controls:NumberBox Minimum="0" Maximum="51"
Value="{Binding Crf}" Value="{Binding Crf}"
@ -109,6 +123,25 @@
</ItemsControl> </ItemsControl>
</StackPanel> </StackPanel>
<StackPanel Margin="0,20,0,0">
<TextBlock Text="Generated FFmpeg Command" Margin="0,0,0,5" />
<Border BorderBrush="#4a4a4a"
Background="{DynamicResource ControlAltFillColorQuarternary}"
BorderThickness="1"
CornerRadius="8"
Padding="10"
MaxWidth="700"
HorizontalAlignment="Left">
<SelectableTextBlock Text="{Binding CommandPreview}"
FontFamily="Cascadia Mono, Consolas, Courier New"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="This preview uses sample input and output file names, but the generated options match the preset."
Opacity="0.7"
FontSize="12"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel> </StackPanel>

View file

@ -45,6 +45,12 @@
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Remove Missing History Episodes" Description="During history refresh, remove episodes no longer available on the streaming service. Seasons with no episodes left are removed too.">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryRemoveMissingEpisodes}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue/count if they weren't downloaded before"> <controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue/count if they weren't downloaded before">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox> <CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
@ -91,6 +97,13 @@
Text="{Binding HistoryAutoRefreshModeHint}" /> Text="{Binding HistoryAutoRefreshModeHint}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding HistoryAutoRefreshAddToQueue}"
Content="Add newly found missing episodes to the queue" />
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="When disabled, auto refresh only updates history and missing counts." />
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" /> <TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
</StackPanel> </StackPanel>
@ -107,6 +120,12 @@
Description="Adjust download settings" Description="Adjust download settings"
IsExpanded="False"> IsExpanded="False">
<controls:SettingsExpanderItem Content="Persist queue" Description="Save the current download queue on exit and restore it on the next start">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding PersistQueue}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Enable New Download Method" Description="Enables the updated download handling logic. This may improve performance and stability."> <controls:SettingsExpanderItem Content="Enable New Download Method" Description="Enables the updated download handling logic. This may improve performance and stability.">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox> <CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
@ -146,19 +165,33 @@
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<StackPanel> <StackPanel>
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<TextBlock Width="100" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock> <TextBlock Width="200" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="10" <controls:NumberBox Minimum="1" Maximum="10"
Value="{Binding RetryAttempts}" Value="{Binding RetryAttempts}"
SpinButtonPlacementMode="Hidden" SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5"> <StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="100" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock> <TextBlock Width="200" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="30" <controls:NumberBox Minimum="1" Maximum="30"
Value="{Binding RetryDelay}" Value="{Binding RetryDelay}"
SpinButtonPlacementMode="Hidden" SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="200" Text="Rate Limit Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="86400"
Value="{Binding PlaybackRateLimitRetryDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="200" Text="Rate Limit Max Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="86400"
Value="{Binding RetryMaxDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
</StackPanel> </StackPanel>
@ -246,96 +279,212 @@
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished"> <controls:SettingsExpander.Footer>
</controls:SettingsExpander.Footer>
</controls:SettingsExpander>
<controls:SettingsExpander Header="Notifications"
IconSource="AlertOn"
Description="Configure sound, file execution, and webhook notifications"
IsExpanded="False">
<controls:SettingsExpanderItem Content="Sound notification" Description="Play a sound file when the queue finishes">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10"> <StackPanel Spacing="10" Width="520">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"> <Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}" <TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedPlaySound}"
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
FontSize="15" FontSize="15"
Opacity="0.8" Opacity="0.8"
TextWrapping="NoWrap" TextWrapping="NoWrap"
TextAlignment="Center" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" /> VerticalAlignment="Center">
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock Text="Select Finished Sound" FontSize="15" /> <TextBlock Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip> </ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5"> </TextBlock>
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedPlaySound}" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
Command="{Binding ClearFinishedSoundPath}" <Button IsVisible="{Binding DownloadFinishedPlaySound}"
VerticalAlignment="Center" Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
FontStyle="Italic"> VerticalAlignment="Center"
<ToolTip.Tip> FontStyle="Italic">
<TextBlock Text="Remove Finished Sound Path" FontSize="15" /> <ToolTip.Tip>
</ToolTip.Tip> <TextBlock Text="Select notification sound" FontSize="15" />
<StackPanel Orientation="Horizontal" Spacing="5"> </ToolTip.Tip>
<controls:SymbolIcon Symbol="Clear" FontSize="18" /> <StackPanel Orientation="Horizontal" Spacing="5">
</StackPanel> <controls:SymbolIcon Symbol="Folder" FontSize="18" />
</Button> </StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox> <Button IsVisible="{Binding DownloadFinishedPlaySound}"
</StackPanel> Command="{Binding TestFinishedSoundAsync}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Test selected notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Play" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding IsTestingFinishedSound}"
Command="{Binding StopFinishedSoundAsync}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Stop notification sound test" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Stop" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding ClearFinishedSoundPath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}" />
</StackPanel>
</Grid>
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="This notification is sent only when the full queue finishes." />
</StackPanel> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete"> <controls:SettingsExpanderItem Content="Execute file" Description="Run a selected file when the queue finishes">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10"> <StackPanel Spacing="10" Width="520">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right"> <Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock IsVisible="{Binding DownloadFinishedExecute}" <TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedExecute}"
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
FontSize="15" FontSize="15"
Opacity="0.8" Opacity="0.8"
TextWrapping="NoWrap" TextWrapping="NoWrap"
TextAlignment="Center" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" /> VerticalAlignment="Center">
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" /> <TextBlock Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip> </ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5"> </TextBlock>
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedExecute}" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
Command="{Binding ClearFinishedExectuePath}" <Button IsVisible="{Binding DownloadFinishedExecute}"
VerticalAlignment="Center" Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
FontStyle="Italic"> VerticalAlignment="Center"
<ToolTip.Tip> FontStyle="Italic">
<TextBlock Text="Clear selected file" FontSize="15" /> <ToolTip.Tip>
</ToolTip.Tip> <TextBlock Text="Select file to execute" FontSize="15" />
<StackPanel Orientation="Horizontal" Spacing="5"> </ToolTip.Tip>
<controls:SymbolIcon Symbol="Clear" FontSize="18" /> <StackPanel Orientation="Horizontal" Spacing="5">
</StackPanel> <controls:SymbolIcon Symbol="Folder" FontSize="18" />
</Button> </StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox> <Button IsVisible="{Binding DownloadFinishedExecute}"
</StackPanel> Command="{Binding ClearFinishedExectuePath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected file" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}" />
</StackPanel>
</Grid>
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="This action runs only when the full queue finishes." />
</StackPanel> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Webhook" Description="Send an HTTP request when selected notification events are raised">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10" Width="520">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBox Watermark="https://example.com/webhook"
MinWidth="420"
Text="{Binding WebhookUrl}" />
<CheckBox IsChecked="{Binding WebhookEnabled}" />
</StackPanel>
<controls:SettingsExpander.Footer> <StackPanel Orientation="Horizontal" Spacing="10">
</controls:SettingsExpander.Footer> <StackPanel Spacing="4">
<TextBlock Text="Method" />
<TextBox MinWidth="120" Text="{Binding WebhookMethod}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Content type" />
<TextBox MinWidth="220" Text="{Binding WebhookContentType}" />
</StackPanel>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Headers" />
<TextBox AcceptsReturn="True"
Height="90"
Text="{Binding WebhookHeadersText}"
Watermark="Authorization: Bearer token&#10;X-App: CRD" />
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="Enter one header per line in the format Name: Value." />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Body template" />
<TextBox AcceptsReturn="True"
Height="120"
Text="{Binding WebhookBodyTemplate}"
Watermark="JSON template using placeholders like {{eventType}} and {{message}}" />
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="Leave empty to send the default JSON payload. Available placeholders include {{eventType}}, {{title}}, {{message}}, {{timestampUtc}}, and metadata keys." />
</StackPanel>
<WrapPanel ItemWidth="245" Orientation="Horizontal">
<CheckBox IsChecked="{Binding WebhookNotifyQueueFinished}" Content="Queue finished" />
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFinished}" Content="Download finished" />
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFailed}" Content="Download failed" />
<CheckBox IsChecked="{Binding WebhookNotifyTrackedSeriesEpisodeReleased}" Content="Tracked series episode released" />
<CheckBox IsChecked="{Binding WebhookNotifyLoginExpired}" Content="Login expired" />
<CheckBox IsChecked="{Binding WebhookNotifyUpdateAvailable}" Content="Update available" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Command="{Binding TestWebhookCommand}"
Content="Send selected test events" />
<ProgressBar Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsTestingWebhook}" />
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander> </controls:SettingsExpander>
<controls:SettingsExpander Header="Sonarr Settings" <controls:SettingsExpander Header="Sonarr Settings"
@ -776,7 +925,7 @@
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm if traffic is routed through a VPN"> <controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm that traffic is routed through a VPN.&#10;After enabling the VPN or changing location, restart the app; otherwise, Crunchyroll may still see the old login location.">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<Grid VerticalAlignment="Center"> <Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>

33
Dockerfile.webtop Normal file
View file

@ -0,0 +1,33 @@
ARG WEBTOP_TAG=ubuntu-xfce
FROM lscr.io/linuxserver/webtop:${WEBTOP_TAG}
USER root
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache ffmpeg mkvtoolnix; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg mkvtoolnix \
&& rm -rf /var/lib/apt/lists/*; \
else \
echo "Unsupported base image: no apk or apt-get found" >&2; \
exit 1; \
fi
COPY docker/crd-linux-x64/ /opt/crd/
COPY docker/crd.desktop /usr/share/applications/crd.desktop
COPY CRD/Assets/app_icon.png /opt/crd/crd-icon.png
COPY docker/50-crd-shortcuts /custom-cont-init.d/50-crd-shortcuts
RUN chmod +x /opt/crd/CRD /opt/crd/Updater /custom-cont-init.d/50-crd-shortcuts \
&& rm -rf /opt/crd/config /opt/crd/temp /opt/crd/video /opt/crd/presets /opt/crd/fonts /opt/crd/widevine /opt/crd/lib /opt/crd/logfile.txt \
&& ln -s /crd-data/config /opt/crd/config \
&& ln -s /crd-data/temp /opt/crd/temp \
&& ln -s /crd-data/video /opt/crd/video \
&& ln -s /crd-data/presets /opt/crd/presets \
&& ln -s /crd-data/fonts /opt/crd/fonts \
&& ln -s /crd-data/widevine /opt/crd/widevine \
&& ln -s /crd-data/lib /opt/crd/lib \
&& ln -s /crd-data/logfile.txt /opt/crd/logfile.txt
WORKDIR /opt/crd

16
docker/50-crd-shortcuts Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/with-contenv sh
set -eu
desktop_dir="/config/Desktop"
autostart_dir="/config/.config/autostart"
desktop_file="/usr/share/applications/crd.desktop"
app_data_dir="/crd-data"
run_uid="${PUID:-1000}"
run_gid="${PGID:-1000}"
mkdir -p "$desktop_dir" "$autostart_dir"
mkdir -p "$app_data_dir/config" "$app_data_dir/temp" "$app_data_dir/video" "$app_data_dir/presets" "$app_data_dir/fonts" "$app_data_dir/widevine" "$app_data_dir/lib"
cp "$desktop_file" "$desktop_dir/CRD.desktop"
cp "$desktop_file" "$autostart_dir/CRD.desktop"
chmod +x "$desktop_dir/CRD.desktop" "$autostart_dir/CRD.desktop"
chown -R "$run_uid:$run_gid" "$app_data_dir" "$desktop_dir" "$autostart_dir"

9
docker/crd.desktop Normal file
View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=CRD
Comment=Crunchy Downloader
Exec=/opt/crd/CRD
Icon=/opt/crd/crd-icon.png
Terminal=false
Categories=AudioVideo;Network;

BIN
images/Settings_G_Notifications.png (Stored with Git LFS) Normal file

Binary file not shown.