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 manager = ProgramManager.Instance;
QueueManager.Instance.RestorePersistedQueue();
if (!isHeadless){
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
@ -38,7 +39,10 @@ public class App : Application{
};
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
desktop.Exit += (_, _) => {
QueueManager.Instance.SaveQueueSnapshot();
manager.StopBackgroundTasks();
};
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
@ -148,7 +152,7 @@ public class App : Application{
}
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 lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
@ -181,4 +185,4 @@ public class App : Application{
}
}
}

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.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){
response = await HttpClientReq.Instance.SendHttpRequest(request);
} else{

View file

@ -13,6 +13,7 @@ using System.Web;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Views;
@ -22,6 +23,8 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60);
public CrToken? Token;
public CrProfile Profile = new();
public Subscription? Subscription{ get; set; }
@ -30,11 +33,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
public CrAuthSettings AuthSettings = authSettings;
public Dictionary<string, CookieCollection> cookieStore = new();
private bool IsTokenExpiredOrNearExpiry(){
return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer;
}
public void Init(){
Profile = new CrProfile{
Username = "???",
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){
if (EndpointEnum == CrunchyrollEndpoints.Guest){
if (Token != null && !(DateTime.Now > Token.expires)){
if (!IsTokenExpiredOrNearExpiry()){
return;
}
@ -418,7 +424,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();
} else{
if (!(DateTime.Now > Token.expires) && needsToken){
if (!IsTokenExpiredOrNearExpiry() && needsToken){
return;
}
}
@ -427,6 +433,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
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;
var formData = new Dictionary<string, string>{
@ -464,6 +472,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
} else{
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.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source;
epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Done = false,
Error = false,
State = DownloadState.Queued,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0
@ -91,4 +89,4 @@ public class CrMovies{
return epMeta;
}
}
}

View file

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

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.Structs;
using CRD.Utils.Muxing.Syncing;
using CRD.Utils.Notifications;
using CRD.Utils.Parser;
using CRD.Utils.Sonarr;
using CRD.Utils.Sonarr.Models;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using CRD.ViewModels;
using CRD.ViewModels.Utils;
using CRD.Views;
@ -76,6 +78,8 @@ public class CrunchyrollManager{
public CrSeries CrSeries;
public CrMovies CrMovies;
public CrMusic CrMusic;
public CrQueue CrQueue;
public History History;
#region Singelton
@ -110,11 +114,13 @@ public class CrunchyrollManager{
options.UseCrBetaApi = true;
options.AutoDownload = false;
options.RemoveFinishedDownload = false;
options.PersistQueue = false;
options.Chapters = true;
options.Hslang = "none";
options.Force = "Y";
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
options.Partsize = 10;
options.DubDownloadDelaySeconds = 0;
options.DlSubs = new List<string>{ "en-US" };
options.SkipMuxing = false;
options.MkvmergeOptions = [];
@ -127,6 +133,8 @@ public class CrunchyrollManager{
options.CcSubsFont = "Trebuchet MS";
options.RetryDelay = 5;
options.RetryAttempts = 5;
options.PlaybackRateLimitRetryDelaySeconds = 30;
options.RetryMaxDelaySeconds = 3600;
options.Numbers = 2;
options.Timeout = 15000;
options.DubLang = new List<string>(){ "ja-JP" };
@ -158,11 +166,13 @@ public class CrunchyrollManager{
};
options.History = true;
options.HistoryRemoveMissingEpisodes = true;
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
options.HistoryAutoRefreshIntervalMinutes = 0;
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
options.NormalizeNotificationSettings();
return options;
}
@ -182,6 +192,7 @@ public class CrunchyrollManager{
CrSeries = new CrSeries();
CrMovies = new CrMovies();
CrMusic = new CrMusic();
CrQueue = new CrQueue();
History = new History();
}
@ -235,30 +246,95 @@ public class CrunchyrollManager{
Video = true,
};
var ghAuth = Updater.Instance.GhAuthJson;
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
if (CrunOptions.StreamEndpoint == null){
CrunOptions.StreamEndpoint = DefaultAndroidTvAuthSettings;
} else if (CrunOptions.StreamEndpoint.UseDefault){
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_type = DefaultAndroidTvAuthSettings.Device_type;
}
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
if (CrunOptions.StreamEndpointSecondSettings == null){
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
} else if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
CrunOptions.StreamEndpointSecondSettings.Authorization = DefaultAndroidAuthSettings.Authorization;
CrunOptions.StreamEndpointSecondSettings.UserAgent = DefaultAndroidAuthSettings.UserAgent;
//---------- TV ----------
//---------- Mobile ----------
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_type = DefaultAndroidAuthSettings.Device_type;
}
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
//---------- Mobile ----------
await CrAuthEndpoint1.Auth();
if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){
await CrAuthEndpoint2.Auth();
@ -337,15 +413,16 @@ public class CrunchyrollManager{
QueueManager.Instance.ReleaseDownloadSlot(data);
}
int retryAttemptCount = data.DownloadProgress.RetryAttemptCount;
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Error = false,
State = DownloadState.Downloading,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Starting"
Doing = "Starting",
RetryAttemptCount = retryAttemptCount
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var res = new DownloadResponse();
try{
res = await DownloadMediaList(data, options);
@ -354,18 +431,31 @@ public class CrunchyrollManager{
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){
ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Error = true,
State = DownloadState.Error,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
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;
}
@ -373,13 +463,13 @@ public class CrunchyrollManager{
if (options.DownloadAllowEarlyStart){
ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Waiting for Muxing/Encoding"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
processingSlotHeld = true;
}
@ -389,16 +479,17 @@ public class CrunchyrollManager{
bool syncError = false;
bool muxError = false;
var notSyncedDubs = "";
var fallbackUsed = false;
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Muxing"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync();
@ -459,14 +550,14 @@ public class CrunchyrollManager{
if (options.IsEncodeEnabled){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
@ -508,6 +599,52 @@ public class CrunchyrollManager{
},
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;
notSyncedDubs = result.notSyncedDubs;
muxError = !result.isMuxed && !data.OnlySubs;
@ -516,16 +653,18 @@ public class CrunchyrollManager{
result.merger.CleanUp();
}
DeleteSyncVideoFiles(res.Data);
if (options.IsEncodeEnabled && !muxError){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
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,
ClosedCaption = d.Cc ?? false,
Signs = d.Signs ?? false,
Delay = d.Delay,
RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia
})
.ToList();
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles);
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed);
}
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
State = DownloadState.Done,
Percent = 100,
Time = 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);
@ -577,8 +716,7 @@ public class CrunchyrollManager{
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
State = DownloadState.Done,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
@ -597,8 +735,8 @@ public class CrunchyrollManager{
}
}
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
var ids = data.Data.First().GetOriginalIds();
@ -609,31 +747,14 @@ public class CrunchyrollManager{
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
}
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
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);
}
}
await NotificationPublisher.Instance.PublishDownloadFinishedAsync(CrunOptions.NotificationSettings, data);
if (CrunOptions.DownloadFinishedExecute){
try{
var filePath = CrunOptions.DownloadFinishedExecutePath;
if (!string.IsNullOrEmpty(filePath)){
Helpers.ExecuteFile(filePath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to execute file: " + exception);
}
}
if (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
await NotificationPublisher.Instance.PublishQueueFinishedAsync(CrunOptions.NotificationSettings, data);
if (CrunOptions.ShutdownWhenQueueEmpty){
CrunOptions.ShutdownWhenQueueEmpty = false;
CfgManager.WriteCrSettings();
Helpers.ShutdownComputer();
}
}
@ -644,18 +765,18 @@ public class CrunchyrollManager{
#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;
data.DownloadProgress = new DownloadProgress{
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Moving Files"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
Console.WriteLine("Invalid or non-existent temp folder path.");
@ -663,15 +784,15 @@ public class CrunchyrollManager{
}
// 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
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)){
// Console.Error.WriteLine("Source file does not exist or path is invalid.");
return;
@ -691,6 +812,7 @@ public class CrunchyrollManager{
: CfgManager.PathVIDEOS_DIR;
var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName);
destinationPath = replaceExisting ? destinationPath : Helpers.GetAvailableDestinationPath(destinationPath);
string? destinationDirectory = Path.GetDirectoryName(destinationPath);
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}");
} catch (IOException ex){
Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}");
@ -717,7 +839,8 @@ public class CrunchyrollManager{
#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;
if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){
@ -728,7 +851,7 @@ public class CrunchyrollManager{
}
} else{
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(),
SkipSubMux = options.SkipSubMux,
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")}",
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,
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(),
@ -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() : [],
});
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");
}
if (!File.Exists(CfgManager.PathMKVMERGE)){
if (!mkvmergeAvailable){
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){
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Muxing Syncing Dub Timings"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var basePath = merger.Options.OnlyVid.First().Path;
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);
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 sourceSubtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle && a.RelatedVideoDownloadMedia == syncVideo).ToList();
if (subtitles.Count <= 0) continue;
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(){
IsDownloading = true,
State = DownloadState.Processing,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Muxing"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
}
if (!options.Mp4 && !muxToMp3){
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE);
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE, crunchyEpMeta.Cts.Token);
} 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){
@ -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 (!File.Exists(CfgManager.PathFFMPEG)){
if (!ffmpegAvailable){
Console.Error.WriteLine("Missing ffmpeg");
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
"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");
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
"https://mkvtoolnix.download/downloads.html#windows");
@ -919,7 +1168,7 @@ public class CrunchyrollManager{
};
}
} 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.");
MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH.");
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.");
MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH.");
return new DownloadResponse{
@ -991,16 +1240,16 @@ public class CrunchyrollManager{
if (data.Data is{ Count: > 0 }){
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;
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
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("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
}
if (options.DownloadDescriptionAudio){
var alreadyAdr = new HashSet<string>(
data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err")
@ -1044,7 +1293,16 @@ public class CrunchyrollManager{
data.Data = sortedMetaData;
var epMetaIndex = 0;
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}");
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
@ -1121,8 +1379,8 @@ public class CrunchyrollManager{
#endregion
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData = default;
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default;
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData = 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)){
fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint);
@ -1133,7 +1391,9 @@ public class CrunchyrollManager{
}
if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){
var errorJson = fetchPlaybackData.error;
var errorJson = !string.IsNullOrEmpty(fetchPlaybackData.error)
? fetchPlaybackData.error
: fetchPlaybackData2.error;
if (!string.IsNullOrEmpty(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")){
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
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)){
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
return new DownloadResponse{
@ -1652,7 +1932,8 @@ public class CrunchyrollManager{
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
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)
.ToArray());
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)){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Downloading,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Decrypting"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
Console.WriteLine("Decryption Needed, attempting to decrypt");
@ -1834,13 +2115,13 @@ public class CrunchyrollManager{
if (videoDownloaded){
Console.WriteLine("Started decrypting video");
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Downloading,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Decrypting video"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandVideo, tempTsFileWorkDir);
@ -1864,18 +2145,8 @@ public class CrunchyrollManager{
Console.WriteLine("Decryption done for video");
if (!options.Nocleanup){
try{
if (File.Exists($"{tempTsFile}.video.enc.m4s")){
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
}
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s");
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s.resume");
}
try{
@ -1905,13 +2176,13 @@ public class CrunchyrollManager{
if (audioDownloaded){
Console.WriteLine("Started decrypting audio");
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Downloading,
Percent = 100,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Decrypting audio"
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandAudio, tempTsFileWorkDir);
@ -1935,18 +2206,8 @@ public class CrunchyrollManager{
Console.WriteLine("Decryption done for audio");
if (!options.Nocleanup){
try{
if (File.Exists($"{tempTsFile}.audio.enc.m4s")){
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
}
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s");
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s.resume");
}
try{
@ -2480,7 +2741,7 @@ public class CrunchyrollManager{
#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){
var temppbData = new PlaybackData{
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);
request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent);
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;
var error = StreamError.FromJson(response.ResponseContent);
@ -2737,4 +2999,4 @@ public class CrunchyrollManager{
private static string BuildShakaKeysParam(List<ContentKey> keys) =>
"--enable_raw_key_decryption " + string.Join(" ",
keys.Select(k => $"--keys key_id={FormatKey(k.KeyID)}:key={FormatKey(k.Bytes)}"));
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
@ -18,6 +18,7 @@ using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using CRD.ViewModels;
using CRD.ViewModels.Utils;
using CRD.Views.Utils;
@ -91,6 +92,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _syncTimings;
[ObservableProperty]
private bool _syncTimingsFullQualityFallback;
[ObservableProperty]
private bool _defaultSubSigns;
@ -115,6 +119,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? _partSize;
[ObservableProperty]
private double? _dubDownloadDelaySeconds;
[ObservableProperty]
private string _fileName = "";
@ -352,6 +359,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
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;
public CrunchyrollSettingsViewModel(){
@ -467,6 +477,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
DefaultSubSigns = options.DefaultSubSigns;
PartSize = options.Partsize;
DubDownloadDelaySeconds = options.DubDownloadDelaySeconds;
IncludeEpisodeDescription = options.IncludeVideoDescription;
FileTitle = options.VideoTitle ?? "";
IncludeSignSubs = options.IncludeSignsSubs;
@ -483,6 +494,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
MuxTypesettingFonts = options.MuxTypesettingFonts;
MuxCover = options.MuxCover;
SyncTimings = options.SyncTiming;
SyncTimingsFullQualityFallback = options.SyncTimingFullQualityFallback;
SkipSubMux = options.SkipSubsMux;
LeadingNumbers = options.Numbers;
FileName = options.FileName;
@ -559,6 +571,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
CrunchyrollManager.Instance.CrunOptions.SyncTimingFullQualityFallback = SyncTimingsFullQualityFallback;
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);
CrunchyrollManager.Instance.CrunOptions.FileName = FileName;
@ -566,6 +579,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
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.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
@ -776,7 +790,18 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[RelayCommand]
public void ResetEndpointSettings(){
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;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
@ -792,6 +817,15 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public void ResetFirstEndpointSettings(){
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;
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];
@ -886,4 +920,4 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
return options;
}
}
}

View file

@ -255,6 +255,17 @@
</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.Footer>
<StackPanel>
@ -585,6 +596,12 @@
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux 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>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
@ -634,6 +651,15 @@
</ItemsControl.ItemTemplate>
</ComboBox>
</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>
@ -821,4 +847,4 @@
</ScrollViewer>
</UserControl>
</UserControl>

View file

@ -59,31 +59,59 @@ public class History{
if (parsedSeries.Data != null){
var result = false;
foreach (var s in parsedSeries.Data){
var sId = s.Id;
if (s.Versions is{ Count: > 0 }){
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){
if (sVersion.Guid != null){
sId = sVersion.Guid;
}
var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
? crunInstance.DefaultLocale
: crunInstance.CrunOptions.HistoryLang;
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 }){
result = true;
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
foreach (var candidateId in candidateIds){
try{
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);
if (historySeries != null){
RemoveUnavailableEpisodes(historySeries);
if (historySeries.Seasons.Count == 0){
crunInstance.HistoryList.Remove(historySeries);
CfgManager.UpdateHistoryFile();
return result;
}
MatchHistorySeriesWithSonarr(false);
await MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile();
@ -263,10 +291,7 @@ public class History{
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
if (historySeries != null){
historySeries.HistorySeriesAddDate ??= DateTime.Now;
historySeries.SeriesType = firstEpisode.GetSeriesType();
historySeries.SeriesStreamingService = StreamingService.Crunchyroll;
await RefreshSeriesData(seriesId, historySeries);
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId());
if (historySeason != null){
@ -307,6 +332,7 @@ public class History{
historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum();
historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate();
historyEpisode.EpisodeType = historySource.GetEpisodeType();
historyEpisode.EpisodeSeriesType = historySource.GetSeriesType();
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
@ -325,6 +351,9 @@ public class History{
newSeason.Init();
}
historySeries.SeriesType = InferSeriesType(historySeries);
await RefreshSeriesData(seriesId, historySeries);
_ = historySeries.LoadImage();
historySeries.UpdateNewEpisodes();
} else if (!string.IsNullOrEmpty(seriesId)){
historySeries = new HistorySeries{
@ -332,7 +361,7 @@ public class History{
SeriesId = firstEpisode.GetSeriesId(),
Seasons = [],
HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(),
SeriesType = SeriesType.Unknown,
SeriesStreamingService = StreamingService.Crunchyroll
};
crunInstance.HistoryList.Add(historySeries);
@ -341,9 +370,10 @@ public class History{
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
await RefreshSeriesData(seriesId, historySeries);
historySeries.Seasons.Add(newSeason);
historySeries.SeriesType = InferSeriesType(historySeries);
await RefreshSeriesData(seriesId, historySeries);
_ = historySeries.LoadImage();
historySeries.UpdateNewEpisodes();
historySeries.Init();
newSeason.Init();
@ -515,7 +545,7 @@ public class History{
private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){
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);
if (seriesData is{ Data: not null }){
var firstEpisode = seriesData.Data.First();
@ -658,6 +688,31 @@ public class History{
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){
// var series = await crunInstance.CrSeries.SeriesById(seriesId);
@ -670,6 +725,39 @@ public class History{
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){
var newSeason = new HistorySeason{
@ -696,6 +784,7 @@ public class History{
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
EpisodeType = historySource.GetEpisodeType(),
EpisodeSeriesType = historySource.GetSeriesType(),
IsEpisodeAvailableOnStreamingService = true,
ThumbnailImageUrl = historySource.GetImageUrl(),
};
@ -775,7 +864,7 @@ public class History{
List<HistoryEpisode> failedEpisodes = [];
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
var episodesCopy = new List<SonarrEpisode>(episodes);
@ -989,4 +1078,4 @@ public class NumericStringPropertyComparer : IComparer<HistoryEpisode>{
// Fall back to string comparison if not parseable as doubles
return string.Compare(x?.Episode, y?.Episode, StringComparison.Ordinal);
}
}
}

View file

@ -15,6 +15,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Notifications;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
@ -54,12 +55,14 @@ public sealed partial class ProgramManager : ObservableObject{
#region Startup Param Variables
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
bool historyRefreshAdded = false;
bool historyRefreshAdded;
private bool exitOnTaskFinish;
#endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
private bool historyRefreshNotificationsArmed;
private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10);
public IStorageProvider? StorageProvider;
@ -100,7 +103,6 @@ public sealed partial class ProgramManager : ObservableObject{
internal async Task RefreshHistory(FilterType filterType){
FetchingData = true;
List<HistorySeries> filteredItems;
var historyList = CrunchyrollManager.Instance.HistoryList;
@ -149,6 +151,7 @@ public sealed partial class ProgramManager : ObservableObject{
FetchingData = false;
CrunchyrollManager.Instance.History.SortItems();
await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance);
}
private async Task AddMissingToQueue(){
@ -158,7 +161,7 @@ public sealed partial class ProgramManager : ObservableObject{
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...");
await Task.Delay(2000);
}
@ -186,23 +189,27 @@ public sealed partial class ProgramManager : ObservableObject{
return;
}
var tasks = crunchyManager.HistoryList
.Select(item => item.AddNewMissingToDownloads(true));
if (crunOptions.HistoryAutoRefreshAddToQueue){
var tasks = crunchyManager.HistoryList
.Select(item => item.AddNewMissingToDownloads(true));
await Task.WhenAll(tasks);
await Task.WhenAll(tasks);
}
if (Application.Current is App app){
Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
}
historyRefreshNotificationsArmed = true;
}
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data ?? [];
var newEpisodes = newEpisodesBase?.Data ?? [];
if (newEpisodesBase is{ Data.Count: > 0 }){
try{
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
@ -210,6 +217,114 @@ public sealed partial class ProgramManager : ObservableObject{
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(){
@ -224,6 +339,7 @@ public sealed partial class ProgramManager : ObservableObject{
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
await Updater.Instance.CheckGhJsonAsync();
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
@ -308,4 +424,4 @@ public sealed partial class ProgramManager : ObservableObject{
checkForNewEpisodesRunner.Stop();
}
}
}

View file

@ -5,27 +5,44 @@ using System.Collections.Specialized;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.CustomList;
using CRD.Utils.QueueManagement;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.ViewModels;
using CRD.Views;
using ReactiveUI;
namespace CRD.Downloader;
public sealed partial class QueueManager : ObservableObject{
public static QueueManager Instance{ get; } = new();
#region Download Variables
#region Download Variables
public RefreshableObservableCollection<CrunchyEpMeta> Queue{ get; } = new();
public ObservableCollection<DownloadItemModel> DownloadItemModels{ get; } = new();
private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = 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{
get{
lock (downloadStartLock){
@ -34,18 +51,7 @@ public sealed partial class QueueManager : ObservableObject{
}
}
private readonly object downloadStartLock = new();
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
public bool HasActiveDownloads => ActiveDownloads > 0;
[ObservableProperty]
private bool hasFailedItem;
@ -55,18 +61,43 @@ public sealed partial class QueueManager : ObservableObject{
private readonly CrunchyrollManager crunchyrollManager;
public QueueManager(){
this.crunchyrollManager = CrunchyrollManager.Instance;
crunchyrollManager = CrunchyrollManager.Instance;
activeProcessingJobs = new SemaphoreSlim(
initialCount: crunchyrollManager.CrunOptions.SimultaneousProcessingJobs,
maxCount: 2);
uiMutationQueue = new UiMutationQueue();
queuePersistenceManager = new QueuePersistenceManager(this);
Queue = new ReadOnlyObservableCollection<CrunchyEpMeta>(queue);
processingJobsLimit = crunchyrollManager.CrunOptions.SimultaneousProcessingJobs;
processingSlots = new ProcessingSlotManager(
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
Queue.CollectionChanged += UpdateItemListOnRemove;
Queue.CollectionChanged += (_, _) => OnQueueStateChanged();
queue.CollectionChanged += UpdateItemListOnRemove;
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){
var item = model.epMeta;
@ -74,13 +105,36 @@ public sealed partial class QueueManager : ObservableObject{
if (activeOrStarting.Contains(item))
return false;
if (item.DownloadProgress is{ IsDownloading: true })
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
return false;
if (item.DownloadProgress is{ Done: true })
if (item.DownloadProgress.IsDone)
return false;
if (item.DownloadProgress is{ Error: true })
if (item.DownloadProgress.IsError)
return false;
if (item.DownloadProgress.IsPaused)
return false;
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
return false;
activeOrStarting.Add(item);
}
NotifyDownloadStateChanged();
OnQueueStateChanged();
_ = model.StartDownloadCore();
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)
@ -89,8 +143,8 @@ public sealed partial class QueueManager : ObservableObject{
activeOrStarting.Add(item);
}
NotifyDownloadStateChanged();
OnQueueStateChanged();
_ = model.StartDownloadCore();
return true;
}
@ -102,110 +156,98 @@ public sealed partial class QueueManager : ObservableObject{
}
if (removed){
NotifyDownloadStateChanged();
OnQueueStateChanged();
RequestPump();
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
}
}
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
return activeProcessingJobs.WaitAsync(cancellationToken);
return processingSlots.WaitAsync(cancellationToken);
}
public void ReleaseProcessingSlot(){
lock (processingLock){
if (borrowed > 0){
borrowed--;
return;
}
activeProcessingJobs.Release();
}
processingSlots.Release();
}
public void SetLimit(int newLimit){
if (newLimit < 0)
throw new ArgumentOutOfRangeException(nameof(newLimit));
public void SetProcessingLimit(int newLimit){
processingSlots.SetLimit(newLimit);
}
lock (processingLock){
if (newLimit == processingJobsLimit)
return;
public void RestorePersistedQueue(){
queuePersistenceManager.RestoreQueue();
}
int delta = newLimit - processingJobsLimit;
public void SaveQueueSnapshot(){
queuePersistenceManager.SaveNow();
}
if (delta > 0){
int giveBack = Math.Min(borrowed, delta);
borrowed -= giveBack;
internal List<CrunchyEpMeta> GetQueueSnapshot(){
if (Dispatcher.UIThread.CheckAccess()){
return queue.ToList();
}
int toRelease = delta - giveBack;
if (toRelease > 0)
activeProcessingJobs.Release(toRelease);
} else{
int toRemove = -delta;
return Dispatcher.UIThread
.InvokeAsync(() => queue.ToList())
.GetAwaiter()
.GetResult();
}
while (toRemove > 0 && activeProcessingJobs.Wait(0)){
toRemove--;
}
borrowed += toRemove;
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> items){
uiMutationQueue.Enqueue(() => {
queue.Clear();
foreach (var item in items){
if (!queue.Contains(item))
queue.Add(item);
}
processingJobsLimit = newLimit;
}
RestoreRetryStateFromQueue();
UpdateDownloadListItems();
});
}
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
if (e.Action == NotifyCollectionChangedAction.Remove){
if (e.OldItems != null)
foreach (var eOldItem in e.OldItems){
var downloadItem = DownloadItemModels.FirstOrDefault(downloadItem => downloadItem.epMeta.Equals(eOldItem));
if (downloadItem != null){
DownloadItemModels.Remove(downloadItem);
} else{
Console.Error.WriteLine("Failed to Remove Episode from list");
}
if (e.OldItems != null){
foreach (var oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
downloadItems.Remove(oldItem);
}
} else if (e.Action == NotifyCollectionChangedAction.Reset && Queue.Count == 0){
DownloadItemModels.Clear();
}
} else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
downloadItems.Clear();
}
UpdateDownloadListItems();
}
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
Avalonia.Threading.Dispatcher.UIThread.Post(() => {
uiMutationQueue.Enqueue(() => {
if (removeFromQueue){
if (Queue.Contains(item))
Queue.Remove(item);
int index = queue.IndexOf(item);
if (index >= 0)
queue.RemoveAt(index);
} else{
Queue.Refresh();
queue.Refresh();
}
OnQueueStateChanged();
}, Avalonia.Threading.DispatcherPriority.Background);
});
}
public void UpdateDownloadListItems(){
foreach (CrunchyEpMeta crunchyEpMeta in Queue.ToList()){
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
if (downloadItem != null){
downloadItem.Refresh();
} else{
downloadItem = new DownloadItemModel(crunchyEpMeta);
_ = downloadItem.LoadImage();
DownloadItemModels.Add(downloadItem);
}
}
downloadItems.SyncFromQueue(queue);
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
}
public void RequestPump(){
private void RequestPump(){
Interlocked.Exchange(ref pumpDirty, 1);
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
@ -223,7 +265,7 @@ public sealed partial class QueueManager : ObservableObject{
}
} finally{
Interlocked.Exchange(ref pumpScheduled, 0);
if (Volatile.Read(ref pumpDirty) == 1 &&
Interlocked.CompareExchange(ref pumpScheduled, 1, 0) == 0){
Avalonia.Threading.Dispatcher.UIThread.Post(
@ -234,8 +276,27 @@ public sealed partial class QueueManager : ObservableObject{
}
private void PumpQueue(){
List<CrunchyEpMeta> toStart = new();
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> toResume = new();
bool changed = false;
lock (downloadStartLock){
int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads;
int freeSlots = Math.Max(0, limit - activeOrStarting.Count);
@ -243,17 +304,20 @@ public sealed partial class QueueManager : ObservableObject{
if (freeSlots == 0)
return;
foreach (var item in Queue.ToList()){
foreach (var item in queue.ToList()){
if (freeSlots == 0)
break;
if (item.DownloadProgress.Error)
if (item.DownloadProgress.IsError)
continue;
if (item.DownloadProgress.Done)
if (item.DownloadProgress.IsWaitingForRetry)
continue;
if (item.DownloadProgress.IsDownloading)
if (item.DownloadProgress.IsDone)
continue;
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
continue;
if (activeOrStarting.Contains(item))
@ -261,12 +325,29 @@ public sealed partial class QueueManager : ObservableObject{
activeOrStarting.Add(item);
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){
var model = DownloadItemModels.FirstOrDefault(x => x.epMeta.Equals(item));
var model = downloadItems.Find(item);
if (model != null){
_ = model.StartDownloadCore();
} else{
@ -277,431 +358,110 @@ public sealed partial class QueueManager : ObservableObject{
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(){
QueueStateChanged?.Invoke(this, EventArgs.Empty);
}
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;
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));
private void NotifyDownloadStateChanged(){
OnPropertyChanged(nameof(ActiveDownloads));
OnPropertyChanged(nameof(HasActiveDownloads));
}
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 NAudio.Wave;
namespace CRD.Utils;
public class AudioPlayer{
private readonly Player _player;
private bool _isPlaying = false;
private bool _isPlaying;
private WaveOutEvent? _waveOut;
private AudioFileReader? _audioFileReader;
private TaskCompletionSource? _playbackCompleted;
public AudioPlayer(){
_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){
Console.WriteLine("Audio is already playing, ignoring duplicate request.");
return;
}
_isPlaying = true;
await _player.Play(path);
_isPlaying = false;
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;
} 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(){
await _player.Stop();
_isPlaying = false;
public async Task StopAsync(){
if (OperatingSystem.IsWindows()){
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[] identifierBlob = new byte[0];
public bool canDecrypt = false;
public bool canDecrypt;
#region Singelton
@ -114,7 +114,7 @@ public class Widevine{
// 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++){
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
response = await HttpClientReq.Instance.SendHttpRequest(request);

View file

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

View file

@ -21,6 +21,7 @@ public class CfgManager{
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 PathCrQueue = Path.Combine(workingDirectory, "config", "queue.json");
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
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");
private static StreamWriter logFile;
private static bool isLogModeEnabled = false;
private static bool isLogModeEnabled;
static CfgManager(){
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
@ -366,4 +367,14 @@ public class CfgManager{
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;
public class HlsDownloader{
private readonly CancellationToken _cancellationToken;
private Data _data = new();
private CrunchyEpMeta _currentEpMeta;
@ -24,12 +25,24 @@ public class HlsDownloader{
private bool _isAudio;
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){
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
throw new Exception("Playlist is empty");
}
_currentEpMeta = meta;
_cancellationToken = meta.Cts.Token;
_isVideo = isVideo;
_isAudio = isAudio;
@ -62,6 +75,7 @@ public class HlsDownloader{
public async Task<(bool Ok, PartsData Parts)> Download(){
_cancellationToken.ThrowIfCancellationRequested();
string fn = _data.OutputFile ?? string.Empty;
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
@ -141,8 +155,8 @@ public class HlsDownloader{
try{
var initDl = await DownloadPart(initSeg, 0, 0);
await File.WriteAllBytesAsync(fn, initDl);
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }));
await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }), _cancellationToken);
Console.WriteLine("Init part downloaded.");
} catch (Exception e){
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
@ -185,7 +199,7 @@ public class HlsDownloader{
}
try{
await Task.WhenAll(keyTasks.Values);
await Task.WhenAll(keyTasks.Values).WaitAsync(_cancellationToken);
} catch (Exception ex){
Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
throw;
@ -200,7 +214,7 @@ public class HlsDownloader{
}
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;
foreach (var task in partTasks){
if (task.Value == completedTask){
@ -234,7 +248,7 @@ public class HlsDownloader{
while (attempt < 3 && !writeSuccess){
try{
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;
@ -242,7 +256,7 @@ public class HlsDownloader{
Console.Error.WriteLine(ex);
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");
await Task.Delay(_data.WaitTime);
await Task.Delay(_data.WaitTime, _cancellationToken);
attempt++;
}
}
@ -267,12 +281,17 @@ public class HlsDownloader{
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
? $"{dataLog.DownloadSpeedBytes * 8 / 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
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})");
_currentEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
State = DownloadState.Downloading,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
@ -280,7 +299,7 @@ public class HlsDownloader{
};
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){
if (!_currentEpMeta.DownloadProgress.IsDone){
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
try{
if (File.Exists(downloadItemDownloadedFile)){
@ -295,13 +314,11 @@ public class HlsDownloader{
return (Ok: false, _data.Parts);
}
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
while (_currentEpMeta.Paused){
await Task.Delay(500);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
return (Ok: false, _data.Parts);
}
await WaitWhilePausedAsync(_cancellationToken);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
return (Ok: false, _data.Parts);
}
}
}
@ -311,10 +328,110 @@ public class HlsDownloader{
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){
var totalSeg = _data.Parts.Total;
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);
@ -328,6 +445,7 @@ public class HlsDownloader{
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
mergedParts = (int?)resumeData?.MergedParts ?? 0;
} catch{
// ignored
}
}
@ -336,133 +454,76 @@ public class HlsDownloader{
var semaphore = new SemaphoreSlim(_data.Threads);
var downloadTasks = new List<Task>();
bool errorOccurred = false;
int errorOccurred = 0;
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var cts = new CancellationTokenSource();
var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
var token = cts.Token;
for (int i = 0; i < segments.Count; i++){
try{
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));
void CleanupBufferedArtifacts(bool cleanAll = true){
CleanupNewDownloadMethod(tempDir, resumeFile, cleanAll);
}
try{
await Task.WhenAll(downloadTasks);
} catch (OperationCanceledException){
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){
CleanupNewDownloadMethod(tempDir, resumeFile, true);
for (int i = 0; i < segments.Count; i++){
try{
await semaphore.WaitAsync(token);
} catch (OperationCanceledException){
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);
}
if (errorOccurred)
return (false, _data.Parts);
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
for (int i = mergedParts; i < segments.Count; i++){
if (token.IsCancellationRequested)
if (token.IsCancellationRequested){
CleanupBufferedArtifacts();
return (false, _data.Parts);
}
string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp");
if (!File.Exists(tempFile)){
Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge.");
CleanupBufferedArtifacts(false);
return (false, _data.Parts);
}
byte[] data = await File.ReadAllBytesAsync(tempFile);
await output.WriteAsync(data, 0, data.Length);
byte[] data = await File.ReadAllBytesAsync(tempFile, token);
await output.WriteAsync(data, 0, data.Length, token);
mergedParts++;
@ -475,21 +536,24 @@ public class HlsDownloader{
var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes);
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{
IsDownloading = true,
State = DownloadState.Processing,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
};
QueueManager.Instance.Queue.Refresh();
QueueManager.Instance.RefreshQueue();
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){
CleanupNewDownloadMethod(tempDir, resumeFile, true);
}
CleanupBufferedArtifacts();
return (false, _data.Parts);
}
}
@ -503,16 +567,9 @@ public class HlsDownloader{
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
if (cleanAll){
// Delete downloaded files
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}");
}
}
CleanupDownloadedFiles();
}
// Delete temp directory
try{
if (Directory.Exists(tempDir))
@ -565,6 +622,7 @@ public class HlsDownloader{
}
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
_cancellationToken.ThrowIfCancellationRequested();
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
byte[]? dec = null;
int p = segIndex;
@ -572,7 +630,7 @@ public class HlsDownloader{
byte[]? part;
if (seg.Key != null){
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;
using (decipher){
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
@ -583,7 +641,7 @@ public class HlsDownloader{
Interlocked.Add(ref _data.TotalBytes, dec.Length);
}
} 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;
if (dec != null){
Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
@ -646,7 +704,7 @@ public class HlsDownloader{
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
if (!_data.Keys.ContainsKey(kUri)){
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){
throw new Exception("Key not fully downloaded or is incorrect.");
}
@ -662,7 +720,8 @@ public class HlsDownloader{
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
if (uri.StartsWith("file://")){
string path = new Uri(uri).LocalPath;
@ -680,17 +739,18 @@ public class HlsDownloader{
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;
for (int attempt = 0; attempt < retryCount + 1; attempt++){
cancellationToken.ThrowIfCancellationRequested();
using (var request = CloneHttpRequestMessage(requestPara)){
try{
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
return await ReadContentAsByteArrayAsync(response.Content);
return await ReadContentAsByteArrayAsync(response.Content, cancellationToken);
} catch (Exception ex) when (ex is HttpRequestException or IOException){
// Log retry attempts
string partType = isKey ? "Key" : "Part";
@ -700,7 +760,7 @@ public class HlsDownloader{
if (attempt == retryCount)
throw; // rethrow after last retry
await Task.Delay(_data.WaitTime);
await Task.Delay(_data.WaitTime, cancellationToken);
} catch (Exception ex){
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
Console.Error.WriteLine($"\tType: {ex.GetType()}");
@ -713,14 +773,14 @@ public class HlsDownloader{
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 contentStream = await content.ReadAsStreamAsync())
using (var contentStream = await content.ReadAsStreamAsync(cancellationToken))
using (var throttledStream = new ThrottledStream(contentStream)){
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await throttledStream.ReadAsync(buffer, 0, buffer.Length)) > 0){
await memoryStream.WriteAsync(buffer, 0, bytesRead);
while ((bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0){
await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
}
return memoryStream.ToArray();
@ -757,7 +817,7 @@ public class HlsDownloader{
}
public static class HttpContentExtensions{
public static HttpContent Clone(this HttpContent content){
public static HttpContent? Clone(this HttpContent? content){
if (content == null) return null;
var memStream = new MemoryStream();
content.CopyToAsync(memStream).Wait();
@ -841,4 +901,4 @@ public class PartsData{
public int First{ get; set; }
public int Total{ get; set; }
public int Completed{ get; set; }
}
}

View file

@ -24,14 +24,40 @@ using CRD.Utils.Http;
using CRD.Utils.JsonConv;
using CRD.Utils.Parser;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using FluentAvalonia.UI.Controls;
using Microsoft.Win32;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using NuGet.Versioning;
namespace CRD.Utils;
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){
try{
serializerSettings ??= new JsonSerializerSettings();
@ -62,6 +88,19 @@ public class Helpers{
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){
var settings = new JsonSerializerSettings{
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{
using (var process = new Process()){
process.StartInfo.FileName = bin;
@ -224,7 +263,17 @@ public class Helpers{
process.BeginOutputReadLine();
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;
@ -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)){
return;
return false;
}
try{
if (File.Exists(filePath)){
for (int attempt = 0; attempt < maxRetries; attempt++){
try{
if (!File.Exists(filePath)){
return true;
}
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){
Process? process = null;
DataReceivedEventHandler? outputHandler = null;
DataReceivedEventHandler? errorHandler = null;
try{
using (var process = new Process()){
process.StartInfo.WorkingDirectory = workingDir;
process.StartInfo.FileName = bin;
process.StartInfo.Arguments = command;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process = new Process{
StartInfo = new ProcessStartInfo{
WorkingDirectory = workingDir,
FileName = bin,
Arguments = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
outputHandler = (_, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
process.ErrorDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine($"{e.Data}");
}
};
errorHandler = (_, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine(e.Data);
}
};
process.Start();
process.OutputDataReceived += outputHandler;
process.ErrorDataReceived += errorHandler;
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.Start();
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){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
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{
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
_ =>["-crf", preset.Crf.ToString()]
"h264_nvenc" or "hevc_nvenc"
=> preset.Crf is >= 0 and <= 51 ? ["-cq", q] : [],
"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(
string inputFilePath,
VideoPreset preset,
@ -311,29 +453,7 @@ public class Helpers{
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
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);
var args = BuildFFmpegArgsForPreset(inputFilePath, preset, tempOutput);
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
int exitCode;
@ -409,7 +529,7 @@ public class Helpers{
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){
if (string.IsNullOrWhiteSpace(s))
return "\"\"";
@ -960,4 +1080,4 @@ public class Helpers{
return false;
}
}
}
}

View file

@ -112,9 +112,10 @@ public class HttpClientReq{
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){
string content = string.Empty;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try{
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
AttachCookies(request, cookieStore);
@ -131,7 +132,7 @@ public class HttpClientReq{
retryRequest, GetCookiesForRequest(cookieStore));
if (!solverResult.IsOk){
return (false, solverResult.ResponseContent, "Challenge bypass failed");
return (false, solverResult.ResponseContent, "Challenge bypass failed",headers);
}
// foreach (var cookie in solverResult.Cookies){
@ -139,30 +140,36 @@ public class HttpClientReq{
// 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();
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();
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
return (IsOk: true, ResponseContent: content, error: "");
return (IsOk: true, ResponseContent: content, error: "",headers);
} catch (Exception e){
if (!suppressError){
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){
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured");
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",[]);
string content = string.Empty;
try{
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
@ -170,13 +177,13 @@ public class HttpClientReq{
content = flareSolverrResponses.ResponseContent;
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "");
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "",[]);
} catch (Exception e){
if (!suppressError){
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.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
using CRD.Views;
@ -108,55 +108,27 @@ public class FontsManager{
{ "Webdings", "webdings.ttf" }
};
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
private readonly FontIndex index = new();
private int _fontSourceNoticePrinted;
private void EnsureIndex(string fontsDir){
index.Rebuild(fontsDir);
index.Rebuild(GetFontSearchDirectories(fontsDir));
}
public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts...");
var fonts = Fonts.Values.ToList();
foreach (var font in fonts){
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}");
}
public Task GetFontsAsync(){
try{
Directory.CreateDirectory(CfgManager.PathFONTS_DIR);
} catch (Exception e){
Console.Error.WriteLine($"Failed to create fonts directory '{CfgManager.PathFONTS_DIR}': {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){
@ -273,7 +245,7 @@ public class FontsManager{
}
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;
}
@ -288,8 +260,8 @@ public class FontsManager{
return true;
if (Fonts.TryGetValue(req, out var crFile)){
var p = Path.Combine(fontsDir, crFile);
if (File.Exists(p)){
var p = FindKnownFontFile(crFile, fontsDir);
if (!string.IsNullOrEmpty(p)){
resolvedPath = p;
return true;
}
@ -303,27 +275,44 @@ public class FontsManager{
return true;
if (Fonts.TryGetValue(family, out var crFamilyFile)){
var p = Path.Combine(fontsDir, crFamilyFile);
if (File.Exists(p)){
var p = FindKnownFontFile(crFamilyFile, fontsDir);
if (!string.IsNullOrEmpty(p)){
resolvedPath = p;
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;
}
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)$",
"", RegexOptions.IgnoreCase).Trim();
var styleWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase){
"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))
return string.Empty;
@ -332,17 +321,24 @@ public class FontsManager{
if (s.StartsWith("@"))
s = s.Substring(1);
// Convert camel case (TimesNewRoman → Times New Roman)
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
// unify separators
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;
}
private static string RemoveSpaces(string s)
=> s.Replace(" ", "");
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
var baseName = Path.GetFileName(path);
@ -356,23 +352,79 @@ public class FontsManager{
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 readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
public void Rebuild(string fontsDir){
public void Rebuild(IEnumerable<string> fontDirs){
map.Clear();
if (!Directory.Exists(fontsDir)) return;
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"))
foreach (var fontsDir in fontDirs){
if (!Directory.Exists(fontsDir))
continue;
foreach (var desc in LoadDescriptions(path)){
foreach (var alias in BuildAliases(desc)){
Add(alias, path);
try{
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;
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){
var family = d.FontFamilyInvariantCulture.Trim();
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily"
var family = d.FontFamilyInvariantCulture?.Trim() ?? string.Empty;
var sub = d.FontSubFamilyNameInvariantCulture?.Trim() ?? string.Empty; // Regular/Bold/Italic
var full = d.FontNameInvariantCulture?.Trim() ?? string.Empty; // "Family Subfamily"
if (!string.IsNullOrWhiteSpace(family)) yield return family;
if (!string.IsNullOrWhiteSpace(full)) yield return full;
@ -461,4 +513,4 @@ public class FontsManager{
public class SubtitleFonts{
public LanguageItem Language{ get; set; } = new();
public Dictionary<string, string> Fonts{ get; set; } = new();
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Muxing.Commands;
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{
"ffmpeg" => FFmpeg(),
"mkvmerge" => MkvMerge(),
@ -40,7 +41,7 @@ public class Merger{
}
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){
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");

View file

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

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){
string baseFramesDir, baseFramesDirEnd;
string compareFramesDir, compareFramesDirEnd;
string cleanupDir;
string cleanupDir = string.Empty;
double baseEndWindowOffset = 0;
double compareEndWindowOffset = 0;
try{
var tempDir = CfgManager.PathTEMP_DIR;
string uuid = Guid.NewGuid().ToString();
@ -46,8 +48,13 @@ public class VideoSyncer{
return (-100, 0, 0, 0);
}
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
var baseEndWindowDuration = Math.Min(360, baseVideoDurationTimeSpan.Value.TotalSeconds);
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){
Console.Error.WriteLine("Failed to extract Frames to Compare");
@ -57,24 +64,24 @@ public class VideoSyncer{
// Load frames from start of the videos
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate)
}).ToList();
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate, 0)
}).OrderBy(frame => frame.Time).ToList();
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate)
}).ToList();
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate, 0)
}).OrderBy(frame => frame.Time).ToList();
// Load frames from end of the videos
var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{
FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate)
}).ToList();
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate, baseEndWindowOffset)
}).OrderBy(frame => frame.Time).ToList();
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
FilePath = fp,
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate)
}).ToList();
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate, compareEndWindowOffset)
}).OrderBy(frame => frame.Time).ToList();
// Calculate offsets
@ -83,13 +90,14 @@ public class VideoSyncer{
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($"End offset: {endOffset} seconds");
CleanupDirectory(cleanupDir);
baseFramesStart.Clear();
baseFramesEnd.Clear();
compareFramesStart.Clear();
@ -112,21 +120,23 @@ public class VideoSyncer{
} catch (Exception e){
Console.Error.WriteLine(e);
return (-100, 0, 0, 0);
} finally{
CleanupDirectory(cleanupDir);
}
}
private static void CleanupDirectory(string dirPath){
if (Directory.Exists(dirPath)){
if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)){
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+)");
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){
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){
var locale = match.Groups[1].Value; // Capture the locale 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 CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Sonarr;
using CRD.ViewModels;
using Newtonsoft.Json;
using System;
namespace CRD.Utils.Structs.Crunchyroll;
@ -21,6 +23,9 @@ public class CrDownloadOptions{
[JsonProperty("remove_finished_downloads")]
public bool RemoveFinishedDownload{ get; set; }
[JsonProperty("persist_queue")]
public bool PersistQueue{ get; set; }
[JsonIgnore]
public int Timeout{ get; set; }
@ -29,6 +34,12 @@ public class CrDownloadOptions{
[JsonProperty("retry_attempts")]
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]
public string Force{ get; set; } = "";
@ -65,6 +76,9 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_execute_path")]
public string? DownloadFinishedExecutePath{ get; set; }
[JsonProperty("notifications")]
public NotificationSettings? NotificationSettings{ get; set; }
[JsonProperty("download_only_with_all_selected_dubsub")]
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
@ -92,6 +106,9 @@ public class CrDownloadOptions{
[JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; }
[JsonProperty("history_remove_missing_episodes")]
public bool HistoryRemoveMissingEpisodes{ get; set; } = true;
[JsonProperty("history_lang")]
public string? HistoryLang{ get; set; }
@ -111,6 +128,12 @@ public class CrDownloadOptions{
[JsonProperty("history_auto_refresh_mode")]
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")]
public SonarrProperties? SonarrProperties{ get; set; }
@ -224,6 +247,9 @@ public class CrDownloadOptions{
[JsonProperty("download_part_size")]
public int Partsize{ get; set; }
[JsonProperty("dub_download_delay_seconds")]
public int DubDownloadDelaySeconds{ get; set; }
[JsonProperty("soft_subs")]
public List<string> DlSubs{ get; set; } =[];
@ -317,6 +343,9 @@ public class CrDownloadOptions{
[JsonProperty("mux_sync_dubs")]
public bool SyncTiming{ get; set; }
[JsonProperty("mux_sync_fallback_full_quality")]
public bool SyncTimingFullQualityFallback{ get; set; }
[JsonProperty("mux_sync_hwaccel")]
public string? FfmpegHwAccelFlag{ get; set; }
@ -360,4 +389,46 @@ public class CrDownloadOptions{
public bool SearchFetchFeaturedMusic{ get; set; }
#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(){
if (SeasonTitle.Contains("OVA", StringComparison.Ordinal) ||
SeasonTitle.Contains("Special", StringComparison.Ordinal) ||
SeasonTitle.Contains("Extra", StringComparison.Ordinal)){
return true;
}
if (string.IsNullOrEmpty(Identifier)){
return false;
}
@ -285,7 +291,20 @@ public class CrunchyEpisode : IHistorySource{
}
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(){
@ -373,7 +392,6 @@ public class CrunchyEpMeta{
public string? AbsolutEpisodeNumberE{ get; set; }
public string? Image{ get; set; }
public string? ImageBig{ get; set; }
public bool Paused{ get; set; }
public DownloadProgress DownloadProgress{ get; set; } = new();
public List<string>? SelectedDubs{ get; set; }
@ -385,6 +403,7 @@ public class CrunchyEpMeta{
public string? DownloadPath{ get; set; }
public string? VideoQuality{ get; set; }
public List<string> DownloadSubs{ get; set; } =[];
public string? TempFileSuffix{ get; set; }
public bool Music{ get; set; }
public string Resolution{ get; set; }
@ -399,18 +418,74 @@ public class CrunchyEpMeta{
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 bool IsDownloading = false;
public bool Done = false;
public bool Error = false;
public DownloadState State{ get; set; } = DownloadState.Queued;
public DownloadState ResumeState{ get; set; } = DownloadState.Downloading;
public string Doing = string.Empty;
public DateTimeOffset? RetryAtUtc{ get; set; }
public int RetryAttemptCount{ get; set; }
public int Percent{ get; set; }
public double Time{ 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{
@ -436,4 +511,4 @@ public class CrunchyEpMetaData{
public class CrunchyRollEpisodeData{
public string Key{ get; set; }
public EpisodeAndLanguage EpisodeAndLanguages{ get; set; }
}
}

View file

@ -6,14 +6,22 @@ namespace CRD.Utils.Structs.Crunchyroll;
public class StreamError{
[JsonPropertyName("error")]
public string Error{ get; set; }
public string? Error{ get; set; }
[JsonPropertyName("activeStreams")]
public List<ActiveStream> ActiveStreams{ get; set; } = new ();
[JsonIgnore]
public string? RawJson{ get; set; }
public static StreamError? FromJson(string json){
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){
Console.Error.WriteLine(e);
return null;
@ -23,6 +31,14 @@ public class StreamError{
public bool IsTooManyActiveStreamsError(){
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{
@ -91,4 +107,4 @@ public class ActiveStream{
[JsonPropertyName("lastKeepAliveTimestamp")]
public long LastKeepAliveTimestamp{ get; set; }
}
}

View file

@ -125,6 +125,8 @@ public class DownloadResponse{
public string VideoTitle{ get; set; }
public bool Error{ get; set; }
public string ErrorText{ get; set; }
public bool RetrySuggested{ get; set; }
public int RetryDelaySeconds{ get; set; }
}
public class DownloadedMedia : SxItem{
@ -133,6 +135,7 @@ public class DownloadedMedia : SxItem{
public bool IsPrimary{ get; set; }
public int bitrate{ get; set; }
public int? Delay{ get; set; }
public bool? Cc{ get; set; }
public bool? Signs{ get; set; }
@ -211,4 +214,4 @@ public class SeasonDialogArgs{
Series = series;
Season = season;
}
}
}

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Notifications;
using Newtonsoft.Json;
using NuGet.Versioning;
@ -24,6 +25,7 @@ public class Updater : ObservableObject{
public double Progress;
public bool Failed;
public string LatestVersion = "";
public List<GithubJson> GhAuthJson = [];
public static Updater Instance{ get; } = new();
@ -109,12 +111,19 @@ public class Updater : ObservableObject{
}
downloadUrl = asset.BrowserDownloadUrl;
await NotificationPublisher.Instance.PublishUpdateAvailableAsync(
CrunchyrollManager.Instance.CrunOptions.NotificationSettings,
currentVersion.ToString(),
selectedRelease.TagName,
platformName,
downloadUrl);
_ = UpdateChangelogAsync();
return true;
}
Console.WriteLine("No updates available.");
NotificationPublisher.Instance.ResetUpdateAvailableNotification();
_ = UpdateChangelogAsync();
return false;
}
@ -123,6 +132,25 @@ public class Updater : ObservableObject{
return false;
}
}
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(){
var client = HttpClientReq.Instance.GetHttpClient();
@ -138,7 +166,15 @@ public class Updater : ObservableObject{
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.");
return;
}
@ -180,10 +216,16 @@ public class Updater : ObservableObject{
return string.Empty;
string[] lines = File.ReadAllLines(changelogFilePath);
foreach (string line in lines){
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]");
if (match.Success)
return match.Groups[1].Value;
Match match = Regex.Match(line, @"^## \[(v?[^\]]+)\]");
if (!match.Success)
continue;
string versionText = match.Groups[1].Value;
if (NuGetVersion.TryParse(versionText.TrimStart('v'), out _))
return versionText;
}
return string.Empty;
@ -312,6 +354,18 @@ public class Updater : ObservableObject{
OnPropertyChanged(nameof(Failed));
}
}
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{
[JsonProperty("tag_name")]
@ -325,7 +379,7 @@ public class Updater : ObservableObject{
public bool Prerelease{ get; set; }
}
public class GithubAsset{
[JsonProperty("url")]
public string Url{ get; set; } = "";
@ -365,12 +419,10 @@ public class Updater : ObservableObject{
[JsonProperty("browser_download_url")]
public string BrowserDownloadUrl{ get; set; } = "";
public bool IsForPlatform(string platform){
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,15 +20,20 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Notifications;
using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
using CRD.Views;
using FluentAvalonia.Styling;
using ReactiveUI;
namespace CRD.ViewModels.Utils;
public partial class GeneralSettingsViewModel : ViewModelBase{
private readonly AudioPlayer notificationTestPlayer = new();
[ObservableProperty]
private string currentVersion;
@ -43,6 +48,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool historyIncludeCrArtists;
[ObservableProperty]
private bool historyRemoveMissingEpisodes;
[ObservableProperty]
private bool historyAddSpecials;
@ -59,6 +67,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private HistoryRefreshMode historyAutoRefreshMode;
[ObservableProperty]
private bool historyAutoRefreshAddToQueue;
[ObservableProperty]
private string historyAutoRefreshModeHint;
@ -86,6 +97,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool downloadAllowEarlyStart;
[ObservableProperty]
private bool persistQueue;
[ObservableProperty]
private double? downloadSpeed;
@ -97,6 +111,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? retryDelay;
[ObservableProperty]
private double? playbackRateLimitRetryDelaySeconds;
[ObservableProperty]
private double? retryMaxDelaySeconds;
[ObservableProperty]
private bool trayIconEnabled;
@ -285,9 +305,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
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]
private string currentIp = "";
[ObservableProperty]
private bool isTestingFinishedSound;
[ObservableProperty]
private bool isTestingWebhook;
private readonly FluentAvaloniaTheme faTheme;
private bool settingsLoaded;
@ -312,16 +374,29 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions;
options.NormalizeNotificationSettings();
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
BackgroundImageOpacity = options.BackgroundImageOpacity;
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
var soundProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound);
DownloadFinishedSoundPath = soundProvider?.Path ?? string.Empty;
DownloadFinishedPlaySound = soundProvider?.Enabled ?? false;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
DownloadFinishedExecute = options.DownloadFinishedExecute;
var executeProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute);
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;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -365,19 +440,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
ProxyPort = options.ProxyPort;
HistoryCountMissing = options.HistoryCountMissing;
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
HistoryRemoveMissingEpisodes = options.HistoryRemoveMissingEpisodes;
HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr;
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
HistoryAutoRefreshAddToQueue = options.HistoryAutoRefreshAddToQueue;
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew;
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
PersistQueue = options.PersistQueue;
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
PlaybackRateLimitRetryDelaySeconds = Math.Clamp(options.PlaybackRateLimitRetryDelaySeconds, 1, 86400);
RetryMaxDelaySeconds = Math.Clamp(options.RetryMaxDelaySeconds, 1, 86400);
DownloadToTempFolder = options.DownloadToTempFolder;
SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
@ -417,34 +497,65 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var settings = CrunchyrollManager.Instance.CrunOptions;
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
settings.DownloadFinishedExecute = DownloadFinishedExecute;
settings.NotificationSettings ??= new NotificationSettings();
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.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
settings.PersistQueue = PersistQueue;
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10);
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.HistoryCountMissing = HistoryCountMissing;
settings.HistoryAddSpecials = HistoryAddSpecials;
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistoryRemoveMissingEpisodes = HistoryRemoveMissingEpisodes;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr;
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
settings.HistoryAutoRefreshAddToQueue = HistoryAutoRefreshAddToQueue;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 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.ProxySocks = ProxySocks;
@ -519,6 +630,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.LogMode = LogMode;
CfgManager.WriteCrSettings();
if (!PersistQueue){
QueueManager.Instance.SaveQueueSnapshot();
}
}
[RelayCommand]
@ -613,7 +728,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[RelayCommand]
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;
}
@ -627,21 +745,131 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
},
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;
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
);
}
[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
#region Download Finished Execute File
[RelayCommand]
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;
}
@ -655,10 +883,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
},
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;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute).Path ?? 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){
if (value?.Content?.ToString() == "System"){
@ -758,7 +1172,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(CurrentAppTheme)
or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)){
or nameof(LogMode)
or nameof(PersistQueue)){
return;
}
@ -829,4 +1244,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CfgManager.DisableLogMode();
}
}
}
partial void OnPersistQueueChanged(bool value){
UpdateSettings();
QueueManager.Instance.SaveQueueSnapshot();
}
}

View file

@ -42,6 +42,20 @@
<TextBlock Text="Retry failed" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
</ToolTip.Tip>
</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"
HorizontalAlignment="Right"
@ -108,11 +122,11 @@
HorizontalAlignment="Right" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="{Binding
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" />
ShowPauseIcon, Converter={StaticResource UiValueConverter}}" FontSize="18" />
</StackPanel>
</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">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
@ -158,4 +172,4 @@
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</UserControl>
</UserControl>

View file

@ -257,6 +257,16 @@
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" />
</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" />
@ -1272,4 +1282,4 @@
</Grid>
</UserControl>
</UserControl>

View file

@ -38,29 +38,43 @@
<StackPanel>
<TextBlock Text="Enter Codec" Margin="0,10,0,5" />
<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>
<!-- Resolution ComboBox -->
<StackPanel>
<StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Select Resolution" Margin="0,10,0,5" />
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding ResolutionList}"
SelectedItem="{Binding SelectedResolution}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</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>
<!-- Frame Rate NumberBox -->
<StackPanel>
<StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" />
<!-- <controls:NumberBox Minimum="1" Maximum="999" -->
<!-- Value="{Binding FrameRate}" -->
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" /> -->
<TextBox Watermark="24" Text="{Binding FrameRate}" />
<TextBox Watermark="24000/1001" Text="{Binding FrameRate}" />
</StackPanel>
<!-- CRF NumberBox -->
<StackPanel>
<StackPanel IsVisible="{Binding HasCodec}">
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
<controls:NumberBox Minimum="0" Maximum="51"
Value="{Binding Crf}"
@ -109,7 +123,26 @@
</ItemsControl>
</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>
</UserControl>
</UserControl>

View file

@ -45,6 +45,12 @@
</controls:SettingsExpanderItem.Footer>
</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.Footer>
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
@ -91,6 +97,13 @@
Text="{Binding HistoryAutoRefreshModeHint}" />
</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">
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
</StackPanel>
@ -107,6 +120,12 @@
Description="Adjust download settings"
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.Footer>
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
@ -146,19 +165,33 @@
<controls:SettingsExpanderItem.Footer>
<StackPanel>
<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"
Value="{Binding RetryAttempts}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
<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"
Value="{Binding RetryDelay}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</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>
@ -246,96 +279,212 @@
</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>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}"
<StackPanel Spacing="10" Width="520">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedPlaySound}"
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Select Finished Sound" FontSize="15" />
<TextBlock Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
</TextBlock>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding ClearFinishedSoundPath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Remove Finished Sound Path" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
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>
</controls:SettingsExpanderItem.Footer>
</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>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
<StackPanel Spacing="10" Width="520">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedExecute}"
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
<TextBlock Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
</TextBlock>
<Button IsVisible="{Binding DownloadFinishedExecute}"
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>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select file to execute" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
</StackPanel>
<Button IsVisible="{Binding DownloadFinishedExecute}"
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>
</controls:SettingsExpanderItem.Footer>
</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>
</controls:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="10">
<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 Header="Sonarr Settings"
@ -776,7 +925,7 @@
</controls:SettingsExpanderItem.Footer>
</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>
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
@ -824,4 +973,4 @@
</ScrollViewer>
</UserControl>
</UserControl>

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.