mirror of
https://github.com/Crunchy-DL/Crunchy-Downloader.git
synced 2026-05-18 07:51:47 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3e28093e | ||
|
|
d9813191ad | ||
|
|
638c412e49 | ||
|
|
9be2c65eb2 | ||
|
|
2715069ceb | ||
|
|
4952d74aa6 |
65 changed files with 4423 additions and 1287 deletions
14
.dockerignore
Normal file
14
.dockerignore
Normal 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/**
|
||||||
123
.github/workflows/docker-from-release.yml
vendored
Normal file
123
.github/workflows/docker-from-release.yml
vendored
Normal 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 }}
|
||||||
|
|
@ -29,6 +29,7 @@ public class App : Application{
|
||||||
var isHeadless = Environment.GetCommandLineArgs().Contains("--headless");
|
var isHeadless = Environment.GetCommandLineArgs().Contains("--headless");
|
||||||
|
|
||||||
var manager = ProgramManager.Instance;
|
var manager = ProgramManager.Instance;
|
||||||
|
QueueManager.Instance.RestorePersistedQueue();
|
||||||
|
|
||||||
if (!isHeadless){
|
if (!isHeadless){
|
||||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
@ -38,7 +39,10 @@ public class App : Application{
|
||||||
};
|
};
|
||||||
|
|
||||||
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
|
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
|
||||||
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
|
desktop.Exit += (_, _) => {
|
||||||
|
QueueManager.Instance.SaveQueueSnapshot();
|
||||||
|
manager.StopBackgroundTasks();
|
||||||
|
};
|
||||||
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
|
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
|
||||||
|
|
||||||
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
|
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
|
||||||
|
|
@ -148,7 +152,7 @@ public class App : Application{
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateTrayTooltip(){
|
public void UpdateTrayTooltip(){
|
||||||
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => e.DownloadProgress is{ Done: false, Error: false });
|
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => !e.DownloadProgress.IsFinished);
|
||||||
|
|
||||||
var options = CrunchyrollManager.Instance.CrunOptions;
|
var options = CrunchyrollManager.Instance.CrunOptions;
|
||||||
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
|
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
|
||||||
|
|
|
||||||
BIN
CRD/Assets/app_icon.png
(Stored with Git LFS)
Normal file
BIN
CRD/Assets/app_icon.png
(Stored with Git LFS)
Normal file
Binary file not shown.
|
|
@ -82,7 +82,7 @@ public class CalendarManager{
|
||||||
request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
|
request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
|
||||||
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
||||||
|
|
||||||
(bool IsOk, string ResponseContent, string error) response;
|
(bool IsOk, string ResponseContent, string error, Dictionary<string,string> Headers) response;
|
||||||
if (!HttpClientReq.Instance.UseFlareSolverr){
|
if (!HttpClientReq.Instance.UseFlareSolverr){
|
||||||
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||||
} else{
|
} else{
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ using System.Web;
|
||||||
using CRD.Utils;
|
using CRD.Utils;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
using CRD.Utils.Http;
|
using CRD.Utils.Http;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.Crunchyroll;
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using CRD.Views;
|
using CRD.Views;
|
||||||
|
|
@ -22,6 +23,8 @@ using ReactiveUI;
|
||||||
namespace CRD.Downloader.Crunchyroll;
|
namespace CRD.Downloader.Crunchyroll;
|
||||||
|
|
||||||
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
|
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
|
||||||
|
private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
public CrToken? Token;
|
public CrToken? Token;
|
||||||
public CrProfile Profile = new();
|
public CrProfile Profile = new();
|
||||||
public Subscription? Subscription{ get; set; }
|
public Subscription? Subscription{ get; set; }
|
||||||
|
|
@ -33,8 +36,11 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
||||||
|
|
||||||
public Dictionary<string, CookieCollection> cookieStore = new();
|
public Dictionary<string, CookieCollection> cookieStore = new();
|
||||||
|
|
||||||
public void Init(){
|
private bool IsTokenExpiredOrNearExpiry(){
|
||||||
|
return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Init(){
|
||||||
Profile = new CrProfile{
|
Profile = new CrProfile{
|
||||||
Username = "???",
|
Username = "???",
|
||||||
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
|
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
|
||||||
|
|
@ -406,7 +412,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
||||||
|
|
||||||
public async Task RefreshToken(bool needsToken){
|
public async Task RefreshToken(bool needsToken){
|
||||||
if (EndpointEnum == CrunchyrollEndpoints.Guest){
|
if (EndpointEnum == CrunchyrollEndpoints.Guest){
|
||||||
if (Token != null && !(DateTime.Now > Token.expires)){
|
if (!IsTokenExpiredOrNearExpiry()){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,7 +424,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
||||||
Token.access_token != null && Token.refresh_token == null){
|
Token.access_token != null && Token.refresh_token == null){
|
||||||
await AuthAnonymous();
|
await AuthAnonymous();
|
||||||
} else{
|
} else{
|
||||||
if (!(DateTime.Now > Token.expires) && needsToken){
|
if (!IsTokenExpiredOrNearExpiry() && needsToken){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -427,6 +433,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != "???";
|
||||||
|
|
||||||
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
|
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
|
||||||
|
|
||||||
var formData = new Dictionary<string, string>{
|
var formData = new Dictionary<string, string>{
|
||||||
|
|
@ -464,6 +472,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
||||||
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
|
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
|
||||||
} else{
|
} else{
|
||||||
Console.Error.WriteLine("Refresh Token Auth Failed");
|
Console.Error.WriteLine("Refresh Token Auth Failed");
|
||||||
|
if (hadUserSession){
|
||||||
|
await NotificationPublisher.Instance.PublishLoginExpiredAsync(crunInstance.CrunOptions.NotificationSettings, Profile.Username, AuthSettings.Endpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,9 +78,7 @@ public class CrMovies{
|
||||||
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source;
|
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source;
|
||||||
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source;
|
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source;
|
||||||
epMeta.DownloadProgress = new DownloadProgress(){
|
epMeta.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = false,
|
State = DownloadState.Queued,
|
||||||
Done = false,
|
|
||||||
Error = false,
|
|
||||||
Percent = 0,
|
Percent = 0,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0
|
DownloadSpeedBytes = 0
|
||||||
|
|
|
||||||
|
|
@ -184,9 +184,7 @@ public class CrMusic{
|
||||||
epMeta.Image = images.FirstOrDefault()?.Source ?? string.Empty;
|
epMeta.Image = images.FirstOrDefault()?.Source ?? string.Empty;
|
||||||
epMeta.ImageBig = images.FirstOrDefault()?.Source ?? string.Empty;
|
epMeta.ImageBig = images.FirstOrDefault()?.Source ?? string.Empty;
|
||||||
epMeta.DownloadProgress = new DownloadProgress(){
|
epMeta.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = false,
|
State = DownloadState.Queued,
|
||||||
Done = false,
|
|
||||||
Error = false,
|
|
||||||
Percent = 0,
|
Percent = 0,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0
|
DownloadSpeedBytes = 0
|
||||||
|
|
|
||||||
439
CRD/Downloader/Crunchyroll/CrQueue.cs
Normal file
439
CRD/Downloader/Crunchyroll/CrQueue.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,14 @@ using CRD.Utils.Muxing;
|
||||||
using CRD.Utils.Muxing.Fonts;
|
using CRD.Utils.Muxing.Fonts;
|
||||||
using CRD.Utils.Muxing.Structs;
|
using CRD.Utils.Muxing.Structs;
|
||||||
using CRD.Utils.Muxing.Syncing;
|
using CRD.Utils.Muxing.Syncing;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using CRD.Utils.Parser;
|
using CRD.Utils.Parser;
|
||||||
using CRD.Utils.Sonarr;
|
using CRD.Utils.Sonarr;
|
||||||
using CRD.Utils.Sonarr.Models;
|
using CRD.Utils.Sonarr.Models;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.Crunchyroll;
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using CRD.Utils.Structs.History;
|
using CRD.Utils.Structs.History;
|
||||||
|
using CRD.Utils.Updater;
|
||||||
using CRD.ViewModels;
|
using CRD.ViewModels;
|
||||||
using CRD.ViewModels.Utils;
|
using CRD.ViewModels.Utils;
|
||||||
using CRD.Views;
|
using CRD.Views;
|
||||||
|
|
@ -76,6 +78,8 @@ public class CrunchyrollManager{
|
||||||
public CrSeries CrSeries;
|
public CrSeries CrSeries;
|
||||||
public CrMovies CrMovies;
|
public CrMovies CrMovies;
|
||||||
public CrMusic CrMusic;
|
public CrMusic CrMusic;
|
||||||
|
public CrQueue CrQueue;
|
||||||
|
|
||||||
public History History;
|
public History History;
|
||||||
|
|
||||||
#region Singelton
|
#region Singelton
|
||||||
|
|
@ -110,11 +114,13 @@ public class CrunchyrollManager{
|
||||||
options.UseCrBetaApi = true;
|
options.UseCrBetaApi = true;
|
||||||
options.AutoDownload = false;
|
options.AutoDownload = false;
|
||||||
options.RemoveFinishedDownload = false;
|
options.RemoveFinishedDownload = false;
|
||||||
|
options.PersistQueue = false;
|
||||||
options.Chapters = true;
|
options.Chapters = true;
|
||||||
options.Hslang = "none";
|
options.Hslang = "none";
|
||||||
options.Force = "Y";
|
options.Force = "Y";
|
||||||
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
|
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
|
||||||
options.Partsize = 10;
|
options.Partsize = 10;
|
||||||
|
options.DubDownloadDelaySeconds = 0;
|
||||||
options.DlSubs = new List<string>{ "en-US" };
|
options.DlSubs = new List<string>{ "en-US" };
|
||||||
options.SkipMuxing = false;
|
options.SkipMuxing = false;
|
||||||
options.MkvmergeOptions = [];
|
options.MkvmergeOptions = [];
|
||||||
|
|
@ -127,6 +133,8 @@ public class CrunchyrollManager{
|
||||||
options.CcSubsFont = "Trebuchet MS";
|
options.CcSubsFont = "Trebuchet MS";
|
||||||
options.RetryDelay = 5;
|
options.RetryDelay = 5;
|
||||||
options.RetryAttempts = 5;
|
options.RetryAttempts = 5;
|
||||||
|
options.PlaybackRateLimitRetryDelaySeconds = 30;
|
||||||
|
options.RetryMaxDelaySeconds = 3600;
|
||||||
options.Numbers = 2;
|
options.Numbers = 2;
|
||||||
options.Timeout = 15000;
|
options.Timeout = 15000;
|
||||||
options.DubLang = new List<string>(){ "ja-JP" };
|
options.DubLang = new List<string>(){ "ja-JP" };
|
||||||
|
|
@ -158,11 +166,13 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
|
|
||||||
options.History = true;
|
options.History = true;
|
||||||
|
options.HistoryRemoveMissingEpisodes = true;
|
||||||
|
|
||||||
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
|
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
|
||||||
options.HistoryAutoRefreshIntervalMinutes = 0;
|
options.HistoryAutoRefreshIntervalMinutes = 0;
|
||||||
|
|
||||||
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
|
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
|
||||||
|
options.NormalizeNotificationSettings();
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +192,7 @@ public class CrunchyrollManager{
|
||||||
CrSeries = new CrSeries();
|
CrSeries = new CrSeries();
|
||||||
CrMovies = new CrMovies();
|
CrMovies = new CrMovies();
|
||||||
CrMusic = new CrMusic();
|
CrMusic = new CrMusic();
|
||||||
|
CrQueue = new CrQueue();
|
||||||
History = new History();
|
History = new History();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,29 +247,94 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
if (CrunOptions.StreamEndpoint == null){
|
var ghAuth = Updater.Instance.GhAuthJson;
|
||||||
CrunOptions.StreamEndpoint = DefaultAndroidTvAuthSettings;
|
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
|
||||||
} else if (CrunOptions.StreamEndpoint.UseDefault){
|
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
|
||||||
CrunOptions.StreamEndpoint.Authorization = DefaultAndroidTvAuthSettings.Authorization;
|
|
||||||
CrunOptions.StreamEndpoint.UserAgent = DefaultAndroidTvAuthSettings.UserAgent;
|
//---------- TV ----------
|
||||||
|
CrunOptions.StreamEndpoint ??= new CrAuthSettings{
|
||||||
|
Authorization = DefaultAndroidTvAuthSettings.Authorization,
|
||||||
|
UserAgent = DefaultAndroidTvAuthSettings.UserAgent,
|
||||||
|
Device_name = DefaultAndroidTvAuthSettings.Device_name,
|
||||||
|
Device_type = DefaultAndroidTvAuthSettings.Device_type,
|
||||||
|
UseDefault = true
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (CrunOptions.StreamEndpoint.UseDefault){
|
||||||
|
var streamEndpointAuthorization = DefaultAndroidTvAuthSettings.Authorization;
|
||||||
|
var streamEndpointUserAgent = DefaultAndroidTvAuthSettings.UserAgent;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.UserAgent) &&
|
||||||
|
Helpers.CompareClientVersions(
|
||||||
|
Helpers.ExtractClientVersion(CrunOptions.StreamEndpoint.UserAgent),
|
||||||
|
Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
|
||||||
|
streamEndpointAuthorization = CrunOptions.StreamEndpoint.Authorization;
|
||||||
|
streamEndpointUserAgent = CrunOptions.StreamEndpoint.UserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ghAuthTv != null &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
|
||||||
|
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
|
||||||
|
streamEndpointAuthorization = ghAuthTv.Authorization;
|
||||||
|
streamEndpointUserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
|
||||||
|
}
|
||||||
|
|
||||||
|
CrunOptions.StreamEndpoint.Authorization = streamEndpointAuthorization;
|
||||||
|
CrunOptions.StreamEndpoint.UserAgent = streamEndpointUserAgent;
|
||||||
CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name;
|
CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name;
|
||||||
CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type;
|
CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
|
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
|
||||||
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
|
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
|
||||||
|
|
||||||
if (CrunOptions.StreamEndpointSecondSettings == null){
|
//---------- TV ----------
|
||||||
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
|
|
||||||
} else if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
|
|
||||||
CrunOptions.StreamEndpointSecondSettings.Authorization = DefaultAndroidAuthSettings.Authorization;
|
//---------- Mobile ----------
|
||||||
CrunOptions.StreamEndpointSecondSettings.UserAgent = DefaultAndroidAuthSettings.UserAgent;
|
CrunOptions.StreamEndpointSecondSettings ??= new CrAuthSettings{
|
||||||
|
Authorization = DefaultAndroidAuthSettings.Authorization,
|
||||||
|
UserAgent = DefaultAndroidAuthSettings.UserAgent,
|
||||||
|
Device_name = DefaultAndroidAuthSettings.Device_name,
|
||||||
|
Device_type = DefaultAndroidAuthSettings.Device_type,
|
||||||
|
UseDefault = true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
|
||||||
|
var streamEndpointSecondAuthorization = DefaultAndroidAuthSettings.Authorization;
|
||||||
|
var streamEndpointSecondUserAgent = DefaultAndroidAuthSettings.UserAgent;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.UserAgent) &&
|
||||||
|
Helpers.CompareClientVersions(
|
||||||
|
Helpers.ExtractClientVersion(CrunOptions.StreamEndpointSecondSettings.UserAgent),
|
||||||
|
Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
|
||||||
|
streamEndpointSecondAuthorization = CrunOptions.StreamEndpointSecondSettings.Authorization;
|
||||||
|
streamEndpointSecondUserAgent = CrunOptions.StreamEndpointSecondSettings.UserAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ghAuthMobile != null &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
|
||||||
|
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
|
||||||
|
streamEndpointSecondAuthorization = ghAuthMobile.Authorization;
|
||||||
|
streamEndpointSecondUserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
CrunOptions.StreamEndpointSecondSettings.Authorization = streamEndpointSecondAuthorization;
|
||||||
|
CrunOptions.StreamEndpointSecondSettings.UserAgent = streamEndpointSecondUserAgent;
|
||||||
CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name;
|
CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name;
|
||||||
CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type;
|
CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
|
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
|
||||||
|
|
||||||
|
//---------- Mobile ----------
|
||||||
|
|
||||||
await CrAuthEndpoint1.Auth();
|
await CrAuthEndpoint1.Auth();
|
||||||
if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){
|
if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){
|
||||||
await CrAuthEndpoint2.Auth();
|
await CrAuthEndpoint2.Auth();
|
||||||
|
|
@ -337,15 +413,16 @@ public class CrunchyrollManager{
|
||||||
QueueManager.Instance.ReleaseDownloadSlot(data);
|
QueueManager.Instance.ReleaseDownloadSlot(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int retryAttemptCount = data.DownloadProgress.RetryAttemptCount;
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Downloading,
|
||||||
Error = false,
|
|
||||||
Percent = 0,
|
Percent = 0,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Starting"
|
Doing = "Starting",
|
||||||
|
RetryAttemptCount = retryAttemptCount
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
var res = new DownloadResponse();
|
var res = new DownloadResponse();
|
||||||
try{
|
try{
|
||||||
res = await DownloadMediaList(data, options);
|
res = await DownloadMediaList(data, options);
|
||||||
|
|
@ -354,18 +431,31 @@ public class CrunchyrollManager{
|
||||||
res.Error = true;
|
res.Error = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.RetrySuggested){
|
||||||
|
ReleaseDownloadSlotIfHeld();
|
||||||
|
var retryDelay = TimeSpan.FromSeconds(Math.Max(1, res.RetryDelaySeconds));
|
||||||
|
QueueManager.Instance.BlockAutoDownloadUntil(retryDelay, data.Cts.Token);
|
||||||
|
QueueManager.Instance.ScheduleRetry(
|
||||||
|
data,
|
||||||
|
retryDelay,
|
||||||
|
$"Rate limited by playback API. Retrying in {(int)retryDelay.TotalSeconds}s",
|
||||||
|
data.Cts.Token);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.DownloadProgress.ClearRetryState();
|
||||||
|
|
||||||
if (res.Error){
|
if (res.Error){
|
||||||
ReleaseDownloadSlotIfHeld();
|
ReleaseDownloadSlotIfHeld();
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = false,
|
State = DownloadState.Error,
|
||||||
Error = true,
|
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""),
|
Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""),
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
await NotificationPublisher.Instance.PublishDownloadFailedAsync(CrunOptions.NotificationSettings, data, res.ErrorText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,13 +463,13 @@ public class CrunchyrollManager{
|
||||||
if (options.DownloadAllowEarlyStart){
|
if (options.DownloadAllowEarlyStart){
|
||||||
ReleaseDownloadSlotIfHeld();
|
ReleaseDownloadSlotIfHeld();
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Waiting for Muxing/Encoding"
|
Doing = "Waiting for Muxing/Encoding"
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
|
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
|
||||||
processingSlotHeld = true;
|
processingSlotHeld = true;
|
||||||
}
|
}
|
||||||
|
|
@ -389,16 +479,17 @@ public class CrunchyrollManager{
|
||||||
bool syncError = false;
|
bool syncError = false;
|
||||||
bool muxError = false;
|
bool muxError = false;
|
||||||
var notSyncedDubs = "";
|
var notSyncedDubs = "";
|
||||||
|
var fallbackUsed = false;
|
||||||
|
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Muxing"
|
Doing = "Muxing"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
if (options.MuxFonts){
|
if (options.MuxFonts){
|
||||||
await FontsManager.Instance.GetFontsAsync();
|
await FontsManager.Instance.GetFontsAsync();
|
||||||
|
|
@ -459,14 +550,14 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
if (options.IsEncodeEnabled){
|
if (options.IsEncodeEnabled){
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Encoding"
|
Doing = "Encoding"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
||||||
|
|
||||||
|
|
@ -508,6 +599,52 @@ public class CrunchyrollManager{
|
||||||
},
|
},
|
||||||
fileNameAndPath, data);
|
fileNameAndPath, data);
|
||||||
|
|
||||||
|
if (result.syncError && options.SyncTimingFullQualityFallback && result.failedSyncLocales.Count > 0){
|
||||||
|
var fallbackResult = await TrySyncTimingFallbackAsync(res, data, options, result.failedSyncLocales);
|
||||||
|
if (fallbackResult.FallbackReady){
|
||||||
|
fallbackUsed = true;
|
||||||
|
|
||||||
|
if (result.isMuxed && result.merger != null){
|
||||||
|
Helpers.DeleteFile(result.merger.Options.Output);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await MuxStreams(res.Data,
|
||||||
|
new CrunchyMuxOptions{
|
||||||
|
DubLangList = options.DubLang,
|
||||||
|
SubLangList = options.DlSubs,
|
||||||
|
FfmpegOptions = options.FfmpegOptions,
|
||||||
|
SkipSubMux = options.SkipSubsMux,
|
||||||
|
Output = fileNameAndPath,
|
||||||
|
Mp4 = options.Mp4,
|
||||||
|
Mp3 = options.AudioOnlyToMp3,
|
||||||
|
MuxFonts = options.MuxFonts,
|
||||||
|
MuxCover = options.MuxCover,
|
||||||
|
VideoTitle = res.VideoTitle,
|
||||||
|
Novids = options.Novids,
|
||||||
|
NoCleanup = options.Nocleanup,
|
||||||
|
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
|
||||||
|
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
|
||||||
|
MkvmergeOptions = options.MkvmergeOptions,
|
||||||
|
ForceMuxer = options.Force,
|
||||||
|
SyncTiming = false,
|
||||||
|
CcTag = options.CcTag,
|
||||||
|
KeepAllVideos = true,
|
||||||
|
MuxDescription = options.IncludeVideoDescription,
|
||||||
|
DlVideoOnce = false,
|
||||||
|
DefaultSubSigns = options.DefaultSubSigns,
|
||||||
|
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
|
||||||
|
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
|
||||||
|
SignsSubsAsForced = options.SignsSubsAsForced,
|
||||||
|
},
|
||||||
|
fileNameAndPath, data);
|
||||||
|
|
||||||
|
syncError = false;
|
||||||
|
notSyncedDubs = "";
|
||||||
|
} else{
|
||||||
|
result.notSyncedDubs = string.Join(", ", fallbackResult.FailedLocales);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
syncError = result.syncError;
|
syncError = result.syncError;
|
||||||
notSyncedDubs = result.notSyncedDubs;
|
notSyncedDubs = result.notSyncedDubs;
|
||||||
muxError = !result.isMuxed && !data.OnlySubs;
|
muxError = !result.isMuxed && !data.OnlySubs;
|
||||||
|
|
@ -516,16 +653,18 @@ public class CrunchyrollManager{
|
||||||
result.merger.CleanUp();
|
result.merger.CleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeleteSyncVideoFiles(res.Data);
|
||||||
|
|
||||||
if (options.IsEncodeEnabled && !muxError){
|
if (options.IsEncodeEnabled && !muxError){
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Encoding"
|
Doing = "Encoding"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
||||||
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
|
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
|
||||||
|
|
@ -543,22 +682,22 @@ public class CrunchyrollManager{
|
||||||
Language = d.Language,
|
Language = d.Language,
|
||||||
ClosedCaption = d.Cc ?? false,
|
ClosedCaption = d.Cc ?? false,
|
||||||
Signs = d.Signs ?? false,
|
Signs = d.Signs ?? false,
|
||||||
|
Delay = d.Delay,
|
||||||
RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia
|
RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles);
|
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Done,
|
||||||
Done = true,
|
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "")
|
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") + (fallbackUsed ? " - Used full-quality fallback" : "")
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError);
|
QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError);
|
||||||
|
|
@ -577,8 +716,7 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Done,
|
||||||
Done = true,
|
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
|
|
@ -598,7 +736,7 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
|
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
|
||||||
var ids = data.Data.First().GetOriginalIds();
|
var ids = data.Data.First().GetOriginalIds();
|
||||||
|
|
@ -609,31 +747,14 @@ public class CrunchyrollManager{
|
||||||
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
|
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
|
await NotificationPublisher.Instance.PublishDownloadFinishedAsync(CrunOptions.NotificationSettings, data);
|
||||||
if (CrunOptions.DownloadFinishedPlaySound){
|
|
||||||
try{
|
|
||||||
var audioPath = CrunOptions.DownloadFinishedSoundPath;
|
|
||||||
if (!string.IsNullOrEmpty(audioPath)){
|
|
||||||
var player = new AudioPlayer();
|
|
||||||
player.Play(audioPath);
|
|
||||||
}
|
|
||||||
} catch (Exception exception){
|
|
||||||
Console.Error.WriteLine("Failed to play sound: " + exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CrunOptions.DownloadFinishedExecute){
|
if (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
|
||||||
try{
|
await NotificationPublisher.Instance.PublishQueueFinishedAsync(CrunOptions.NotificationSettings, data);
|
||||||
var filePath = CrunOptions.DownloadFinishedExecutePath;
|
|
||||||
if (!string.IsNullOrEmpty(filePath)){
|
|
||||||
Helpers.ExecuteFile(filePath);
|
|
||||||
}
|
|
||||||
} catch (Exception exception){
|
|
||||||
Console.Error.WriteLine("Failed to execute file: " + exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CrunOptions.ShutdownWhenQueueEmpty){
|
if (CrunOptions.ShutdownWhenQueueEmpty){
|
||||||
|
CrunOptions.ShutdownWhenQueueEmpty = false;
|
||||||
|
CfgManager.WriteCrSettings();
|
||||||
Helpers.ShutdownComputer();
|
Helpers.ShutdownComputer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,18 +765,18 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
#region Temp Files Move
|
#region Temp Files Move
|
||||||
|
|
||||||
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles){
|
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles, bool replaceExisting = false){
|
||||||
if (!options.DownloadToTempFolder) return;
|
if (!options.DownloadToTempFolder) return;
|
||||||
|
|
||||||
data.DownloadProgress = new DownloadProgress{
|
data.DownloadProgress = new DownloadProgress{
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Moving Files"
|
Doing = "Moving Files"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
|
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
|
||||||
Console.WriteLine("Invalid or non-existent temp folder path.");
|
Console.WriteLine("Invalid or non-existent temp folder path.");
|
||||||
|
|
@ -663,15 +784,15 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move the main output file
|
// Move the main output file
|
||||||
await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
|
await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
|
||||||
|
|
||||||
// Move the subtitle files
|
// Move the subtitle files
|
||||||
foreach (var downloadedMedia in subtitles){
|
foreach (var downloadedMedia in subtitles){
|
||||||
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
|
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options){
|
private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options, bool replaceExisting = false){
|
||||||
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)){
|
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)){
|
||||||
// Console.Error.WriteLine("Source file does not exist or path is invalid.");
|
// Console.Error.WriteLine("Source file does not exist or path is invalid.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -691,6 +812,7 @@ public class CrunchyrollManager{
|
||||||
: CfgManager.PathVIDEOS_DIR;
|
: CfgManager.PathVIDEOS_DIR;
|
||||||
|
|
||||||
var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName);
|
var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName);
|
||||||
|
destinationPath = replaceExisting ? destinationPath : Helpers.GetAvailableDestinationPath(destinationPath);
|
||||||
|
|
||||||
string? destinationDirectory = Path.GetDirectoryName(destinationPath);
|
string? destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||||
if (string.IsNullOrEmpty(destinationDirectory)){
|
if (string.IsNullOrEmpty(destinationDirectory)){
|
||||||
|
|
@ -704,7 +826,7 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Task.Run(() => File.Move(sourcePath, destinationPath));
|
await Task.Run(() => File.Move(sourcePath, destinationPath, replaceExisting));
|
||||||
Console.WriteLine($"File moved to {destinationPath}");
|
Console.WriteLine($"File moved to {destinationPath}");
|
||||||
} catch (IOException ex){
|
} catch (IOException ex){
|
||||||
Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}");
|
Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}");
|
||||||
|
|
@ -717,7 +839,8 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename, CrunchyEpMeta crunchyEpMeta){
|
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List<string> failedSyncLocales)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename,
|
||||||
|
CrunchyEpMeta crunchyEpMeta){
|
||||||
var muxToMp3 = false;
|
var muxToMp3 = false;
|
||||||
|
|
||||||
if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){
|
if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){
|
||||||
|
|
@ -728,7 +851,7 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
} else{
|
} else{
|
||||||
Console.WriteLine("Skip muxing since no videos are downloaded");
|
Console.WriteLine("Skip muxing since no videos are downloaded");
|
||||||
return (null, false, false, "");
|
return (null, false, false, "", []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -771,10 +894,10 @@ public class CrunchyrollManager{
|
||||||
OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
|
OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
|
||||||
SkipSubMux = options.SkipSubMux,
|
SkipSubMux = options.SkipSubMux,
|
||||||
OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput
|
OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput
|
||||||
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
|
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, Delay = a.Delay, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
|
||||||
Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}",
|
Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}",
|
||||||
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
|
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
|
||||||
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
|
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, Delay = a.Delay, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
|
||||||
KeepAllVideos = options.KeepAllVideos,
|
KeepAllVideos = options.KeepAllVideos,
|
||||||
Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) : [],
|
Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) : [],
|
||||||
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
|
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
|
||||||
|
|
@ -797,11 +920,14 @@ public class CrunchyrollManager{
|
||||||
Cover = options.MuxCover ? data.Where(a => a.Type == DownloadMediaType.Cover).Select(a => new MergerInput{ Path = a.Path ?? string.Empty }).ToList() : [],
|
Cover = options.MuxCover ? data.Where(a => a.Type == DownloadMediaType.Cover).Select(a => new MergerInput{ Path = a.Path ?? string.Empty }).ToList() : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!File.Exists(CfgManager.PathFFMPEG)){
|
bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
|
||||||
|
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
|
||||||
|
|
||||||
|
if (!ffmpegAvailable){
|
||||||
Console.Error.WriteLine("FFmpeg not found");
|
Console.Error.WriteLine("FFmpeg not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(CfgManager.PathMKVMERGE)){
|
if (!mkvmergeAvailable){
|
||||||
Console.Error.WriteLine("MKVmerge not found");
|
Console.Error.WriteLine("MKVmerge not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,14 +937,14 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){
|
if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){
|
||||||
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Muxing – Syncing Dub Timings"
|
Doing = "Muxing – Syncing Dub Timings"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
var basePath = merger.Options.OnlyVid.First().Path;
|
var basePath = merger.Options.OnlyVid.First().Path;
|
||||||
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
|
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
|
||||||
|
|
@ -845,42 +971,162 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
|
|
||||||
var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale);
|
var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale);
|
||||||
audio?.Delay = (int)(delay.offSet * 1000);
|
var delayMs = (int)(delay.offSet * 1000);
|
||||||
|
if (audio != null){
|
||||||
|
audio.Delay = delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceAudio = data.FirstOrDefault(downloadedMedia =>
|
||||||
|
downloadedMedia.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription &&
|
||||||
|
downloadedMedia.Lang.CrLocale == syncVideo.Lang.CrLocale);
|
||||||
|
if (sourceAudio != null){
|
||||||
|
sourceAudio.Delay = delayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
|
var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
|
||||||
|
var sourceSubtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle && a.RelatedVideoDownloadMedia == syncVideo).ToList();
|
||||||
|
|
||||||
if (subtitles.Count <= 0) continue;
|
if (subtitles.Count <= 0) continue;
|
||||||
|
|
||||||
foreach (var subMergerInput in subtitles){
|
foreach (var subMergerInput in subtitles){
|
||||||
subMergerInput.Delay = (int)(delay.offSet * 1000);
|
subMergerInput.Delay = delayMs;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncVideosList.ForEach(syncVideo => {
|
foreach (var sourceSubtitle in sourceSubtitles){
|
||||||
if (syncVideo.Path != null) Helpers.DeleteFile(syncVideo.Path);
|
sourceSubtitle.Delay = delayMs;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Muxing"
|
Doing = "Muxing"
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.Mp4 && !muxToMp3){
|
if (!options.Mp4 && !muxToMp3){
|
||||||
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE);
|
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE, crunchyEpMeta.Cts.Token);
|
||||||
} else{
|
} else{
|
||||||
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG);
|
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG, crunchyEpMeta.Cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs));
|
return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs), notSyncedDubs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool FallbackReady, List<string> FailedLocales)> TrySyncTimingFallbackAsync(DownloadResponse res, CrunchyEpMeta data, CrDownloadOptions options, List<string> failedSyncLocales){
|
||||||
|
var uniqueFailedLocales = failedSyncLocales
|
||||||
|
.Where(locale => !string.IsNullOrWhiteSpace(locale))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (uniqueFailedLocales.Count == 0){
|
||||||
|
return (false, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.DownloadProgress = new DownloadProgress{
|
||||||
|
State = DownloadState.Downloading,
|
||||||
|
Percent = 0,
|
||||||
|
Time = 0,
|
||||||
|
DownloadSpeedBytes = 0,
|
||||||
|
Doing = $"Downloading full-quality fallback video ({string.Join(", ", uniqueFailedLocales)})"
|
||||||
|
};
|
||||||
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
|
foreach (var syncVideo in res.Data.Where(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase)).ToList()){
|
||||||
|
if (!string.IsNullOrEmpty(syncVideo.Path)){
|
||||||
|
Helpers.DeleteFile(syncVideo.Path);
|
||||||
|
Helpers.DeleteFile(syncVideo.Path + ".resume");
|
||||||
|
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Data.RemoveAll(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var fallbackOptions = Helpers.DeepCopy(options) ?? new CrDownloadOptions();
|
||||||
|
|
||||||
|
fallbackOptions.DubLang = uniqueFailedLocales;
|
||||||
|
fallbackOptions.Noaudio = true;
|
||||||
|
fallbackOptions.Novids = false;
|
||||||
|
fallbackOptions.DlSubs = [];
|
||||||
|
fallbackOptions.SkipSubs = true;
|
||||||
|
fallbackOptions.SkipSubsMux = true;
|
||||||
|
fallbackOptions.Chapters = false;
|
||||||
|
fallbackOptions.IncludeVideoDescription = false;
|
||||||
|
fallbackOptions.MuxCover = false;
|
||||||
|
fallbackOptions.DownloadDescriptionAudio = false;
|
||||||
|
fallbackOptions.DlVideoOnce = false;
|
||||||
|
fallbackOptions.SyncTiming = false;
|
||||||
|
fallbackOptions.DownloadFirstAvailableDub = false;
|
||||||
|
fallbackOptions.SkipMuxing = true;
|
||||||
|
|
||||||
|
var originalDataEntries = data.Data;
|
||||||
|
var originalSelectedDubs = data.SelectedDubs != null ? new List<string>(data.SelectedDubs) : null;
|
||||||
|
var originalDownloadSubs = new List<string>(data.DownloadSubs);
|
||||||
|
var originalTempFileSuffix = data.TempFileSuffix;
|
||||||
|
|
||||||
|
data.Data = data.Data
|
||||||
|
.Where(epMeta => epMeta.Lang != null && uniqueFailedLocales.Contains(epMeta.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
data.SelectedDubs = uniqueFailedLocales;
|
||||||
|
data.DownloadSubs = [];
|
||||||
|
data.TempFileSuffix = $"-fallback-{Guid.NewGuid():N}"[..19];
|
||||||
|
|
||||||
|
if (data.Data.Count == 0){
|
||||||
|
data.Data = originalDataEntries;
|
||||||
|
data.SelectedDubs = originalSelectedDubs;
|
||||||
|
data.DownloadSubs = originalDownloadSubs;
|
||||||
|
data.TempFileSuffix = originalTempFileSuffix;
|
||||||
|
return (false, uniqueFailedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadResponse fallbackResponse;
|
||||||
|
try{
|
||||||
|
fallbackResponse = await DownloadMediaList(data, fallbackOptions);
|
||||||
|
} finally{
|
||||||
|
data.Data = originalDataEntries;
|
||||||
|
data.SelectedDubs = originalSelectedDubs;
|
||||||
|
data.DownloadSubs = originalDownloadSubs;
|
||||||
|
data.TempFileSuffix = originalTempFileSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackResponse.Error){
|
||||||
|
return (false, uniqueFailedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallbackVideos = fallbackResponse.Data
|
||||||
|
.Where(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var fallbackVideoLocales = fallbackVideos
|
||||||
|
.Select(media => media.Lang.CrLocale)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (fallbackVideoLocales.Count != uniqueFailedLocales.Count){
|
||||||
|
return (false, uniqueFailedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Data.RemoveAll(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
|
||||||
|
res.Data.AddRange(fallbackVideos);
|
||||||
|
|
||||||
|
return (true, uniqueFailedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteSyncVideoFiles(List<DownloadedMedia> data){
|
||||||
|
foreach (var syncVideo in data.Where(media => media.Type == DownloadMediaType.SyncVideo).ToList()){
|
||||||
|
if (!string.IsNullOrEmpty(syncVideo.Path)){
|
||||||
|
Helpers.DeleteFile(syncVideo.Path);
|
||||||
|
Helpers.DeleteFile(syncVideo.Path + ".resume");
|
||||||
|
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<DownloadResponse> DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){
|
private async Task<DownloadResponse> DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){
|
||||||
|
|
@ -894,8 +1140,11 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
|
||||||
|
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
|
||||||
if (!File.Exists(CfgManager.PathFFMPEG)){
|
if (!ffmpegAvailable){
|
||||||
Console.Error.WriteLine("Missing ffmpeg");
|
Console.Error.WriteLine("Missing ffmpeg");
|
||||||
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
|
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
|
||||||
"https://github.com/GyanD/codexffmpeg/releases/latest");
|
"https://github.com/GyanD/codexffmpeg/releases/latest");
|
||||||
|
|
@ -907,7 +1156,7 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(CfgManager.PathMKVMERGE)){
|
if (!mkvmergeAvailable){
|
||||||
Console.Error.WriteLine("Missing Mkvmerge");
|
Console.Error.WriteLine("Missing Mkvmerge");
|
||||||
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
|
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
|
||||||
"https://mkvtoolnix.download/downloads.html#windows");
|
"https://mkvtoolnix.download/downloads.html#windows");
|
||||||
|
|
@ -919,7 +1168,7 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else{
|
} else{
|
||||||
if (!Helpers.IsInstalled("ffmpeg", "-version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "ffmpeg"))){
|
if (!ffmpegAvailable){
|
||||||
Console.Error.WriteLine("Ffmpeg is not installed or not in the system PATH.");
|
Console.Error.WriteLine("Ffmpeg is not installed or not in the system PATH.");
|
||||||
MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH.");
|
MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH.");
|
||||||
return new DownloadResponse{
|
return new DownloadResponse{
|
||||||
|
|
@ -930,7 +1179,7 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Helpers.IsInstalled("mkvmerge", "--version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "mkvmerge"))){
|
if (!mkvmergeAvailable){
|
||||||
Console.Error.WriteLine("Mkvmerge is not installed or not in the system PATH.");
|
Console.Error.WriteLine("Mkvmerge is not installed or not in the system PATH.");
|
||||||
MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH.");
|
MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH.");
|
||||||
return new DownloadResponse{
|
return new DownloadResponse{
|
||||||
|
|
@ -991,11 +1240,11 @@ public class CrunchyrollManager{
|
||||||
if (data.Data is{ Count: > 0 }){
|
if (data.Data is{ Count: > 0 }){
|
||||||
options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
|
options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
|
||||||
|
|
||||||
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty ,data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
|
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty, data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
|
||||||
SonarrEpisode? sonarrEpisode;
|
SonarrEpisode? sonarrEpisode;
|
||||||
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
|
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
|
||||||
sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId));
|
sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId));
|
||||||
if(sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
|
if (sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
|
||||||
variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true));
|
variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true));
|
||||||
variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
|
variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
|
||||||
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
|
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
|
||||||
|
|
@ -1044,7 +1293,16 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
data.Data = sortedMetaData;
|
data.Data = sortedMetaData;
|
||||||
|
|
||||||
|
var epMetaIndex = 0;
|
||||||
foreach (CrunchyEpMetaData epMeta in data.Data){
|
foreach (CrunchyEpMetaData epMeta in data.Data){
|
||||||
|
if (epMetaIndex > 0 && options.DubDownloadDelaySeconds > 0){
|
||||||
|
var delay = TimeSpan.FromSeconds(options.DubDownloadDelaySeconds);
|
||||||
|
data.DownloadProgress.Doing = $"Waiting {options.DubDownloadDelaySeconds}s before next dub";
|
||||||
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
await Task.Delay(delay, data.Cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
epMetaIndex++;
|
||||||
Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}");
|
Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}");
|
||||||
|
|
||||||
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
|
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
|
||||||
|
|
@ -1121,8 +1379,8 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData = default;
|
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData = default;
|
||||||
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default;
|
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData2 = default;
|
||||||
|
|
||||||
if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){
|
if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){
|
||||||
fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint);
|
fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint);
|
||||||
|
|
@ -1133,7 +1391,9 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){
|
if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){
|
||||||
var errorJson = fetchPlaybackData.error;
|
var errorJson = !string.IsNullOrEmpty(fetchPlaybackData.error)
|
||||||
|
? fetchPlaybackData.error
|
||||||
|
: fetchPlaybackData2.error;
|
||||||
if (!string.IsNullOrEmpty(errorJson)){
|
if (!string.IsNullOrEmpty(errorJson)){
|
||||||
var error = StreamError.FromJson(errorJson);
|
var error = StreamError.FromJson(errorJson);
|
||||||
|
|
||||||
|
|
@ -1147,7 +1407,7 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error?.Error.Contains("Account maturity rating is lower than video rating") == true ||
|
if (error?.Error?.Contains("Account maturity rating is lower than video rating") == true ||
|
||||||
errorJson.Contains("Account maturity rating is lower than video rating")){
|
errorJson.Contains("Account maturity rating is lower than video rating")){
|
||||||
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
|
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
|
||||||
return new DownloadResponse{
|
return new DownloadResponse{
|
||||||
|
|
@ -1158,6 +1418,26 @@ public class CrunchyrollManager{
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error?.IsPlaybackRateLimitError() == true){
|
||||||
|
int retryDelaySeconds = Helpers.GetRetryDelaySeconds(options, data.DownloadProgress.RetryAttemptCount);
|
||||||
|
|
||||||
|
if (fetchPlaybackData.Headers != null &&
|
||||||
|
fetchPlaybackData.Headers.TryGetValue("retry-after", out var retryAfter) &&
|
||||||
|
int.TryParse(retryAfter, out var parsedRetryAfter)){
|
||||||
|
Console.WriteLine($"Retry after: {parsedRetryAfter} seconds");
|
||||||
|
retryDelaySeconds = parsedRetryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"Playback API rate limited with 4294. Requeueing download in {retryDelaySeconds}s.");
|
||||||
|
return new DownloadResponse{
|
||||||
|
Data = new List<DownloadedMedia>(),
|
||||||
|
RetrySuggested = true,
|
||||||
|
RetryDelaySeconds = retryDelaySeconds,
|
||||||
|
FileName = "./unknown",
|
||||||
|
ErrorText = "Rate limit error"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(error?.Error)){
|
if (!string.IsNullOrEmpty(error?.Error)){
|
||||||
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
|
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
|
||||||
return new DownloadResponse{
|
return new DownloadResponse{
|
||||||
|
|
@ -1652,7 +1932,8 @@ public class CrunchyrollManager{
|
||||||
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
|
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
|
||||||
|
|
||||||
string tempFile = Path.Combine(FileNameManager
|
string tempFile = Path.Combine(FileNameManager
|
||||||
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}", variables, options.Numbers, options.FileNameWhitespaceSubstitute,
|
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}{data.TempFileSuffix ?? string.Empty}", variables, options.Numbers,
|
||||||
|
options.FileNameWhitespaceSubstitute,
|
||||||
options.Override)
|
options.Override)
|
||||||
.ToArray());
|
.ToArray());
|
||||||
string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile);
|
string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile);
|
||||||
|
|
@ -1743,13 +2024,13 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){
|
if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Downloading,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Decrypting"
|
Doing = "Decrypting"
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
Console.WriteLine("Decryption Needed, attempting to decrypt");
|
Console.WriteLine("Decryption Needed, attempting to decrypt");
|
||||||
|
|
||||||
|
|
@ -1834,13 +2115,13 @@ public class CrunchyrollManager{
|
||||||
if (videoDownloaded){
|
if (videoDownloaded){
|
||||||
Console.WriteLine("Started decrypting video");
|
Console.WriteLine("Started decrypting video");
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Downloading,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Decrypting video"
|
Doing = "Decrypting video"
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
||||||
commandVideo, tempTsFileWorkDir);
|
commandVideo, tempTsFileWorkDir);
|
||||||
|
|
||||||
|
|
@ -1864,18 +2145,8 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
Console.WriteLine("Decryption done for video");
|
Console.WriteLine("Decryption done for video");
|
||||||
if (!options.Nocleanup){
|
if (!options.Nocleanup){
|
||||||
try{
|
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s");
|
||||||
if (File.Exists($"{tempTsFile}.video.enc.m4s")){
|
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s.resume");
|
||||||
File.Delete($"{tempTsFile}.video.enc.m4s");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists($"{tempTsFile}.video.enc.m4s.resume")){
|
|
||||||
File.Delete($"{tempTsFile}.video.enc.m4s.resume");
|
|
||||||
}
|
|
||||||
} catch (Exception ex){
|
|
||||||
Console.WriteLine($"Failed to delete file {tempTsFile}.video.enc.m4s. Error: {ex.Message}");
|
|
||||||
// Handle exceptions if you need to log them or throw
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try{
|
try{
|
||||||
|
|
@ -1905,13 +2176,13 @@ public class CrunchyrollManager{
|
||||||
if (audioDownloaded){
|
if (audioDownloaded){
|
||||||
Console.WriteLine("Started decrypting audio");
|
Console.WriteLine("Started decrypting audio");
|
||||||
data.DownloadProgress = new DownloadProgress(){
|
data.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Downloading,
|
||||||
Percent = 100,
|
Percent = 100,
|
||||||
Time = 0,
|
Time = 0,
|
||||||
DownloadSpeedBytes = 0,
|
DownloadSpeedBytes = 0,
|
||||||
Doing = "Decrypting audio"
|
Doing = "Decrypting audio"
|
||||||
};
|
};
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
||||||
commandAudio, tempTsFileWorkDir);
|
commandAudio, tempTsFileWorkDir);
|
||||||
|
|
||||||
|
|
@ -1935,18 +2206,8 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
Console.WriteLine("Decryption done for audio");
|
Console.WriteLine("Decryption done for audio");
|
||||||
if (!options.Nocleanup){
|
if (!options.Nocleanup){
|
||||||
try{
|
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s");
|
||||||
if (File.Exists($"{tempTsFile}.audio.enc.m4s")){
|
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s.resume");
|
||||||
File.Delete($"{tempTsFile}.audio.enc.m4s");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists($"{tempTsFile}.audio.enc.m4s.resume")){
|
|
||||||
File.Delete($"{tempTsFile}.audio.enc.m4s.resume");
|
|
||||||
}
|
|
||||||
} catch (Exception ex){
|
|
||||||
Console.WriteLine($"Failed to delete file {tempTsFile}.audio.enc.m4s. Error: {ex.Message}");
|
|
||||||
// Handle exceptions if you need to log them or throw
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try{
|
try{
|
||||||
|
|
@ -2480,7 +2741,7 @@ public class CrunchyrollManager{
|
||||||
|
|
||||||
#region Fetch Playback Data
|
#region Fetch Playback Data
|
||||||
|
|
||||||
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc,
|
private async Task<(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc,
|
||||||
CrAuthSettings optionsStreamEndpointSettings){
|
CrAuthSettings optionsStreamEndpointSettings){
|
||||||
var temppbData = new PlaybackData{
|
var temppbData = new PlaybackData{
|
||||||
Total = 0,
|
Total = 0,
|
||||||
|
|
@ -2514,16 +2775,17 @@ public class CrunchyrollManager{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent);
|
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent, playbackRequestResponse.Headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(bool IsOk, string ResponseContent, string error)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){
|
private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){
|
||||||
var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null);
|
var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null);
|
||||||
request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent);
|
request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent);
|
||||||
return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore);
|
return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(bool IsOk, string ResponseContent, string error)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent, string error) response, string endpoint, CrAuth authEndpoint){
|
private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> HandleStreamErrorsAsync(
|
||||||
|
(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers) response, string endpoint, CrAuth authEndpoint){
|
||||||
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
|
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
|
||||||
|
|
||||||
var error = StreamError.FromJson(response.ResponseContent);
|
var error = StreamError.FromJson(response.ResponseContent);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
|
|
@ -18,6 +18,7 @@ using CRD.Utils.Sonarr;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.Crunchyroll;
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using CRD.Utils.Structs.History;
|
using CRD.Utils.Structs.History;
|
||||||
|
using CRD.Utils.Updater;
|
||||||
using CRD.ViewModels;
|
using CRD.ViewModels;
|
||||||
using CRD.ViewModels.Utils;
|
using CRD.ViewModels.Utils;
|
||||||
using CRD.Views.Utils;
|
using CRD.Views.Utils;
|
||||||
|
|
@ -91,6 +92,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _syncTimings;
|
private bool _syncTimings;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _syncTimingsFullQualityFallback;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _defaultSubSigns;
|
private bool _defaultSubSigns;
|
||||||
|
|
||||||
|
|
@ -115,6 +119,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double? _partSize;
|
private double? _partSize;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double? _dubDownloadDelaySeconds;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _fileName = "";
|
private string _fileName = "";
|
||||||
|
|
||||||
|
|
@ -352,6 +359,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _markAsWatched;
|
private bool _markAsWatched;
|
||||||
|
|
||||||
|
public string FontMuxDisclaimer =>
|
||||||
|
$"Crunchyroll no longer provides the old libass font files. Font muxing now uses installed system fonts and custom files from {CfgManager.PathFONTS_DIR}. If subtitles still report missing fonts, add those files there manually.";
|
||||||
|
|
||||||
private bool settingsLoaded;
|
private bool settingsLoaded;
|
||||||
|
|
||||||
public CrunchyrollSettingsViewModel(){
|
public CrunchyrollSettingsViewModel(){
|
||||||
|
|
@ -467,6 +477,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
|
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
|
||||||
DefaultSubSigns = options.DefaultSubSigns;
|
DefaultSubSigns = options.DefaultSubSigns;
|
||||||
PartSize = options.Partsize;
|
PartSize = options.Partsize;
|
||||||
|
DubDownloadDelaySeconds = options.DubDownloadDelaySeconds;
|
||||||
IncludeEpisodeDescription = options.IncludeVideoDescription;
|
IncludeEpisodeDescription = options.IncludeVideoDescription;
|
||||||
FileTitle = options.VideoTitle ?? "";
|
FileTitle = options.VideoTitle ?? "";
|
||||||
IncludeSignSubs = options.IncludeSignsSubs;
|
IncludeSignSubs = options.IncludeSignsSubs;
|
||||||
|
|
@ -483,6 +494,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
MuxTypesettingFonts = options.MuxTypesettingFonts;
|
MuxTypesettingFonts = options.MuxTypesettingFonts;
|
||||||
MuxCover = options.MuxCover;
|
MuxCover = options.MuxCover;
|
||||||
SyncTimings = options.SyncTiming;
|
SyncTimings = options.SyncTiming;
|
||||||
|
SyncTimingsFullQualityFallback = options.SyncTimingFullQualityFallback;
|
||||||
SkipSubMux = options.SkipSubsMux;
|
SkipSubMux = options.SkipSubsMux;
|
||||||
LeadingNumbers = options.Numbers;
|
LeadingNumbers = options.Numbers;
|
||||||
FileName = options.FileName;
|
FileName = options.FileName;
|
||||||
|
|
@ -559,6 +571,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
|
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
|
||||||
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
|
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
|
||||||
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
|
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
|
||||||
|
CrunchyrollManager.Instance.CrunOptions.SyncTimingFullQualityFallback = SyncTimingsFullQualityFallback;
|
||||||
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
|
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
|
||||||
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);
|
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);
|
||||||
CrunchyrollManager.Instance.CrunOptions.FileName = FileName;
|
CrunchyrollManager.Instance.CrunOptions.FileName = FileName;
|
||||||
|
|
@ -566,6 +579,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
|
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
|
||||||
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
|
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
|
||||||
CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000);
|
CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000);
|
||||||
|
CrunchyrollManager.Instance.CrunOptions.DubDownloadDelaySeconds = Math.Max((int)(DubDownloadDelaySeconds ?? 0), 0);
|
||||||
CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic;
|
CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic;
|
||||||
|
|
||||||
CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
|
CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
|
||||||
|
|
@ -776,6 +790,17 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ResetEndpointSettings(){
|
public void ResetEndpointSettings(){
|
||||||
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
|
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
|
||||||
|
var ghAuth = Updater.Instance.GhAuthJson;
|
||||||
|
|
||||||
|
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
|
||||||
|
if (ghAuthMobile != null &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
|
||||||
|
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
|
||||||
|
defaultSettings.Authorization = ghAuthMobile.Authorization;
|
||||||
|
defaultSettings.UserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
||||||
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
|
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
|
||||||
|
|
@ -792,6 +817,15 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
||||||
public void ResetFirstEndpointSettings(){
|
public void ResetFirstEndpointSettings(){
|
||||||
|
|
||||||
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings;
|
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings;
|
||||||
|
var ghAuth = Updater.Instance.GhAuthJson;
|
||||||
|
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
|
||||||
|
if (ghAuthTv != null &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
|
||||||
|
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
|
||||||
|
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
|
||||||
|
defaultSettings.Authorization = ghAuthTv.Authorization;
|
||||||
|
defaultSettings.UserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
|
||||||
|
}
|
||||||
|
|
||||||
ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
||||||
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];
|
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,17 @@
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
|
|
||||||
|
<controls:SettingsExpanderItem Content="Dub Download Delay"
|
||||||
|
Description="Delay in seconds before starting the next selected dub. 0 disables the delay.">
|
||||||
|
<controls:SettingsExpanderItem.Footer>
|
||||||
|
<controls:NumberBox Minimum="0"
|
||||||
|
Value="{Binding DubDownloadDelaySeconds}"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</controls:SettingsExpanderItem.Footer>
|
||||||
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="Stream Endpoint ">
|
<controls:SettingsExpanderItem Content="Stream Endpoint ">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
@ -585,6 +596,12 @@
|
||||||
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox>
|
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox>
|
||||||
|
|
||||||
<CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox>
|
<CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox>
|
||||||
|
<TextBlock IsVisible="{Binding MuxFonts}"
|
||||||
|
MaxWidth="360"
|
||||||
|
Margin="0,6,0,0"
|
||||||
|
Opacity="0.85"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding FontMuxDisclaimer}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</controls:SettingsExpanderItem.Footer>
|
</controls:SettingsExpanderItem.Footer>
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
@ -634,6 +651,15 @@
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Spacing="8"
|
||||||
|
IsVisible="{Binding SyncTimings}">
|
||||||
|
<TextBlock VerticalAlignment="Center"
|
||||||
|
Text="Mux failed dubs in selected quality" />
|
||||||
|
<CheckBox VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding SyncTimingsFullQualityFallback}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,31 +59,59 @@ public class History{
|
||||||
if (parsedSeries.Data != null){
|
if (parsedSeries.Data != null){
|
||||||
var result = false;
|
var result = false;
|
||||||
foreach (var s in parsedSeries.Data){
|
foreach (var s in parsedSeries.Data){
|
||||||
var sId = s.Id;
|
var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
|
||||||
|
? crunInstance.DefaultLocale
|
||||||
|
: crunInstance.CrunOptions.HistoryLang;
|
||||||
|
|
||||||
|
var candidateIds = new List<string>();
|
||||||
|
|
||||||
if (s.Versions is{ Count: > 0 }){
|
if (s.Versions is{ Count: > 0 }){
|
||||||
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){
|
candidateIds.AddRange(
|
||||||
if (sVersion.Guid != null){
|
s.Versions
|
||||||
sId = sVersion.Guid;
|
.Where(v => v.Original == true && !string.IsNullOrWhiteSpace(v.Guid))
|
||||||
|
.OrderByDescending(v => v.Guid!.Length)
|
||||||
|
.Select(v => v.Guid!)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
if (!string.IsNullOrWhiteSpace(s.Id)){
|
||||||
}
|
candidateIds.Add(s.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(seasonId) && sId != seasonId) continue;
|
candidateIds = candidateIds
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(seasonId) &&
|
||||||
|
!candidateIds.Contains(seasonId, StringComparer.OrdinalIgnoreCase)){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(sId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
foreach (var candidateId in candidateIds){
|
||||||
|
try{
|
||||||
|
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(candidateId, lang, true);
|
||||||
|
|
||||||
if (seasonData.Data is{ Count: > 0 }){
|
if (seasonData.Data is{ Count: > 0 }){
|
||||||
result = true;
|
result = true;
|
||||||
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch{
|
||||||
|
// optional: log candidateId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||||
|
|
||||||
if (historySeries != null){
|
if (historySeries != null){
|
||||||
|
RemoveUnavailableEpisodes(historySeries);
|
||||||
|
if (historySeries.Seasons.Count == 0){
|
||||||
|
crunInstance.HistoryList.Remove(historySeries);
|
||||||
|
CfgManager.UpdateHistoryFile();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
MatchHistorySeriesWithSonarr(false);
|
MatchHistorySeriesWithSonarr(false);
|
||||||
await MatchHistoryEpisodesWithSonarr(false, historySeries);
|
await MatchHistoryEpisodesWithSonarr(false, historySeries);
|
||||||
CfgManager.UpdateHistoryFile();
|
CfgManager.UpdateHistoryFile();
|
||||||
|
|
@ -263,10 +291,7 @@ public class History{
|
||||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||||
if (historySeries != null){
|
if (historySeries != null){
|
||||||
historySeries.HistorySeriesAddDate ??= DateTime.Now;
|
historySeries.HistorySeriesAddDate ??= DateTime.Now;
|
||||||
historySeries.SeriesType = firstEpisode.GetSeriesType();
|
|
||||||
historySeries.SeriesStreamingService = StreamingService.Crunchyroll;
|
historySeries.SeriesStreamingService = StreamingService.Crunchyroll;
|
||||||
|
|
||||||
await RefreshSeriesData(seriesId, historySeries);
|
|
||||||
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId());
|
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId());
|
||||||
|
|
||||||
if (historySeason != null){
|
if (historySeason != null){
|
||||||
|
|
@ -307,6 +332,7 @@ public class History{
|
||||||
historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum();
|
historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum();
|
||||||
historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate();
|
historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate();
|
||||||
historyEpisode.EpisodeType = historySource.GetEpisodeType();
|
historyEpisode.EpisodeType = historySource.GetEpisodeType();
|
||||||
|
historyEpisode.EpisodeSeriesType = historySource.GetSeriesType();
|
||||||
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
|
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
|
||||||
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
|
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
|
||||||
|
|
||||||
|
|
@ -325,6 +351,9 @@ public class History{
|
||||||
newSeason.Init();
|
newSeason.Init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
historySeries.SeriesType = InferSeriesType(historySeries);
|
||||||
|
await RefreshSeriesData(seriesId, historySeries);
|
||||||
|
_ = historySeries.LoadImage();
|
||||||
historySeries.UpdateNewEpisodes();
|
historySeries.UpdateNewEpisodes();
|
||||||
} else if (!string.IsNullOrEmpty(seriesId)){
|
} else if (!string.IsNullOrEmpty(seriesId)){
|
||||||
historySeries = new HistorySeries{
|
historySeries = new HistorySeries{
|
||||||
|
|
@ -332,7 +361,7 @@ public class History{
|
||||||
SeriesId = firstEpisode.GetSeriesId(),
|
SeriesId = firstEpisode.GetSeriesId(),
|
||||||
Seasons = [],
|
Seasons = [],
|
||||||
HistorySeriesAddDate = DateTime.Now,
|
HistorySeriesAddDate = DateTime.Now,
|
||||||
SeriesType = firstEpisode.GetSeriesType(),
|
SeriesType = SeriesType.Unknown,
|
||||||
SeriesStreamingService = StreamingService.Crunchyroll
|
SeriesStreamingService = StreamingService.Crunchyroll
|
||||||
};
|
};
|
||||||
crunInstance.HistoryList.Add(historySeries);
|
crunInstance.HistoryList.Add(historySeries);
|
||||||
|
|
@ -341,9 +370,10 @@ public class History{
|
||||||
|
|
||||||
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||||
|
|
||||||
await RefreshSeriesData(seriesId, historySeries);
|
|
||||||
|
|
||||||
historySeries.Seasons.Add(newSeason);
|
historySeries.Seasons.Add(newSeason);
|
||||||
|
historySeries.SeriesType = InferSeriesType(historySeries);
|
||||||
|
await RefreshSeriesData(seriesId, historySeries);
|
||||||
|
_ = historySeries.LoadImage();
|
||||||
historySeries.UpdateNewEpisodes();
|
historySeries.UpdateNewEpisodes();
|
||||||
historySeries.Init();
|
historySeries.Init();
|
||||||
newSeason.Init();
|
newSeason.Init();
|
||||||
|
|
@ -515,7 +545,7 @@ public class History{
|
||||||
|
|
||||||
private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){
|
private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){
|
||||||
if (cachedSeries == null || (!string.IsNullOrEmpty(cachedSeries.SeriesId) && cachedSeries.SeriesId != seriesId)){
|
if (cachedSeries == null || (!string.IsNullOrEmpty(cachedSeries.SeriesId) && cachedSeries.SeriesId != seriesId)){
|
||||||
if (historySeries.SeriesType == SeriesType.Series){
|
if (historySeries.SeriesType is SeriesType.Series or SeriesType.Movie){
|
||||||
var seriesData = await crunInstance.CrSeries.SeriesById(seriesId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
var seriesData = await crunInstance.CrSeries.SeriesById(seriesId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
||||||
if (seriesData is{ Data: not null }){
|
if (seriesData is{ Data: not null }){
|
||||||
var firstEpisode = seriesData.Data.First();
|
var firstEpisode = seriesData.Data.First();
|
||||||
|
|
@ -658,6 +688,31 @@ public class History{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SeriesType InferSeriesType(HistorySeries? historySeries){
|
||||||
|
var seriesTypes = new List<SeriesType>();
|
||||||
|
|
||||||
|
if (historySeries != null){
|
||||||
|
seriesTypes.AddRange(historySeries.Seasons
|
||||||
|
.SelectMany(season => season.EpisodesList)
|
||||||
|
.Select(episode => episode.EpisodeSeriesType)
|
||||||
|
.Where(type => type != SeriesType.Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesTypes.Count == 0){
|
||||||
|
return historySeries?.SeriesType ?? SeriesType.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesTypes.All(type => type == SeriesType.Artist)){
|
||||||
|
return SeriesType.Artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesTypes.All(type => type == SeriesType.Movie)){
|
||||||
|
return SeriesType.Movie;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SeriesType.Series;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private string GetSeriesThumbnail(CrSeriesBase series){
|
private string GetSeriesThumbnail(CrSeriesBase series){
|
||||||
// var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
// var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
||||||
|
|
@ -670,6 +725,39 @@ public class History{
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RemoveUnavailableEpisodes(HistorySeries historySeries){
|
||||||
|
if (!crunInstance.CrunOptions.HistoryRemoveMissingEpisodes){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasonsToRemove = new List<HistorySeason>();
|
||||||
|
|
||||||
|
foreach (var season in historySeries.Seasons){
|
||||||
|
var unavailableEpisodes = season.EpisodesList
|
||||||
|
.Where(episode => !episode.IsEpisodeAvailableOnStreamingService)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var episode in unavailableEpisodes){
|
||||||
|
season.EpisodesList.Remove(episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season.EpisodesList.Count == 0){
|
||||||
|
seasonsToRemove.Add(season);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
season.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||||
|
season.UpdateDownloaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var season in seasonsToRemove){
|
||||||
|
historySeries.Seasons.Remove(season);
|
||||||
|
}
|
||||||
|
|
||||||
|
historySeries.UpdateNewEpisodes();
|
||||||
|
SortSeasons(historySeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private HistorySeason NewHistorySeason(List<IHistorySource> episodeList, IHistorySource firstEpisode){
|
private HistorySeason NewHistorySeason(List<IHistorySource> episodeList, IHistorySource firstEpisode){
|
||||||
var newSeason = new HistorySeason{
|
var newSeason = new HistorySeason{
|
||||||
|
|
@ -696,6 +784,7 @@ public class History{
|
||||||
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
|
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
|
||||||
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
|
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
|
||||||
EpisodeType = historySource.GetEpisodeType(),
|
EpisodeType = historySource.GetEpisodeType(),
|
||||||
|
EpisodeSeriesType = historySource.GetSeriesType(),
|
||||||
IsEpisodeAvailableOnStreamingService = true,
|
IsEpisodeAvailableOnStreamingService = true,
|
||||||
ThumbnailImageUrl = historySource.GetImageUrl(),
|
ThumbnailImageUrl = historySource.GetImageUrl(),
|
||||||
};
|
};
|
||||||
|
|
@ -775,7 +864,7 @@ public class History{
|
||||||
List<HistoryEpisode> failedEpisodes = [];
|
List<HistoryEpisode> failedEpisodes = [];
|
||||||
|
|
||||||
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
|
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
|
||||||
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
|
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) || rematchAll){
|
||||||
// Create a copy of the episodes list for each thread
|
// Create a copy of the episodes list for each thread
|
||||||
var episodesCopy = new List<SonarrEpisode>(episodes);
|
var episodesCopy = new List<SonarrEpisode>(episodes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CRD.Downloader.Crunchyroll;
|
using CRD.Downloader.Crunchyroll;
|
||||||
using CRD.Utils;
|
using CRD.Utils;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.Crunchyroll;
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using CRD.Utils.Structs.History;
|
using CRD.Utils.Structs.History;
|
||||||
|
|
@ -54,12 +55,14 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
#region Startup Param Variables
|
#region Startup Param Variables
|
||||||
|
|
||||||
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
|
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
|
||||||
bool historyRefreshAdded = false;
|
bool historyRefreshAdded;
|
||||||
private bool exitOnTaskFinish;
|
private bool exitOnTaskFinish;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
|
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
|
||||||
|
private bool historyRefreshNotificationsArmed;
|
||||||
|
private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
public IStorageProvider? StorageProvider;
|
public IStorageProvider? StorageProvider;
|
||||||
|
|
||||||
|
|
@ -100,7 +103,6 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
internal async Task RefreshHistory(FilterType filterType){
|
internal async Task RefreshHistory(FilterType filterType){
|
||||||
FetchingData = true;
|
FetchingData = true;
|
||||||
|
|
||||||
|
|
||||||
List<HistorySeries> filteredItems;
|
List<HistorySeries> filteredItems;
|
||||||
var historyList = CrunchyrollManager.Instance.HistoryList;
|
var historyList = CrunchyrollManager.Instance.HistoryList;
|
||||||
|
|
||||||
|
|
@ -149,6 +151,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
|
|
||||||
FetchingData = false;
|
FetchingData = false;
|
||||||
CrunchyrollManager.Instance.History.SortItems();
|
CrunchyrollManager.Instance.History.SortItems();
|
||||||
|
await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddMissingToQueue(){
|
private async Task AddMissingToQueue(){
|
||||||
|
|
@ -158,7 +161,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
await Task.WhenAll(tasks);
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
|
||||||
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
|
while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
|
||||||
Console.WriteLine("Waiting for downloads to complete...");
|
Console.WriteLine("Waiting for downloads to complete...");
|
||||||
await Task.Delay(2000);
|
await Task.Delay(2000);
|
||||||
}
|
}
|
||||||
|
|
@ -186,23 +189,27 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (crunOptions.HistoryAutoRefreshAddToQueue){
|
||||||
var tasks = crunchyManager.HistoryList
|
var tasks = crunchyManager.HistoryList
|
||||||
.Select(item => item.AddNewMissingToDownloads(true));
|
.Select(item => item.AddNewMissingToDownloads(true));
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
await Task.WhenAll(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
if (Application.Current is App app){
|
if (Application.Current is App app){
|
||||||
Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
|
Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
historyRefreshNotificationsArmed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
|
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
|
||||||
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
|
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
|
||||||
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
|
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
|
||||||
2000, null, true);
|
2000, null, true);
|
||||||
if (newEpisodesBase is{ Data.Count: > 0 }){
|
var newEpisodes = newEpisodesBase?.Data ?? [];
|
||||||
var newEpisodes = newEpisodesBase.Data ?? [];
|
|
||||||
|
|
||||||
|
if (newEpisodesBase is{ Data.Count: > 0 }){
|
||||||
try{
|
try{
|
||||||
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
|
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
|
||||||
CfgManager.UpdateHistoryFile();
|
CfgManager.UpdateHistoryFile();
|
||||||
|
|
@ -210,6 +217,114 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
Console.Error.WriteLine("Failed to update History: " + e.Message);
|
Console.Error.WriteLine("Failed to update History: " + e.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await PublishTrackedSeriesReleaseNotificationsAsync(crunchyManager, newEpisodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager crunchyManager, List<CrBrowseEpisode>? releaseFeedEpisodes = null){
|
||||||
|
var currentCheckTimeUtc = DateTime.UtcNow;
|
||||||
|
var settings = crunchyManager.CrunOptions;
|
||||||
|
var previousCheckUtc = settings.TrackedSeriesReleaseLastCheckUtc;
|
||||||
|
|
||||||
|
if (!historyRefreshNotificationsArmed){
|
||||||
|
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||||
|
CfgManager.WriteCrSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackedSeries = crunchyManager.HistoryList
|
||||||
|
.Where(series => !string.IsNullOrWhiteSpace(series.SeriesId))
|
||||||
|
.ToDictionary(series => series.SeriesId!, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (trackedSeries.Count == 0){
|
||||||
|
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||||
|
CfgManager.WriteCrSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseFeedEpisodes ??= (await crunchyManager.CrEpisode.GetNewEpisodes(
|
||||||
|
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang,
|
||||||
|
2000, null, true))?.Data ?? [];
|
||||||
|
|
||||||
|
var notificationSettings = settings.NotificationSettings;
|
||||||
|
var historyUpdated = false;
|
||||||
|
var windowStartUtc = previousCheckUtc?.Subtract(TrackedSeriesReleaseOverlap);
|
||||||
|
|
||||||
|
foreach (var release in releaseFeedEpisodes){
|
||||||
|
var seriesId = release.EpisodeMetadata?.SeriesId;
|
||||||
|
if (string.IsNullOrWhiteSpace(seriesId) || !trackedSeries.TryGetValue(seriesId, out var historySeries)){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseDateUtc = GetTrackedReleaseDateUtc(release);
|
||||||
|
if (windowStartUtc.HasValue && releaseDateUtc < windowStartUtc.Value){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseDateUtc > currentCheckTimeUtc){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyEpisode = crunchyManager.History.GetHistoryEpisode(seriesId, release.EpisodeMetadata.SeasonId, release.Id ?? string.Empty);
|
||||||
|
if (historyEpisode == null && !string.IsNullOrWhiteSpace(release.Id)){
|
||||||
|
historyEpisode = historySeries.Seasons
|
||||||
|
.SelectMany(season => season.EpisodesList)
|
||||||
|
.FirstOrDefault(episode => episode.EpisodeId == release.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyEpisode == null || historyEpisode.TrackedSeriesReleaseNotified){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notificationSent = await NotificationPublisher.Instance.PublishTrackedSeriesEpisodeReleasedAsync(
|
||||||
|
notificationSettings,
|
||||||
|
historySeries,
|
||||||
|
historyEpisode,
|
||||||
|
release,
|
||||||
|
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang
|
||||||
|
);
|
||||||
|
if (notificationSent){
|
||||||
|
historyEpisode.TrackedSeriesReleaseNotified = true;
|
||||||
|
historyUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||||
|
|
||||||
|
if (historyUpdated){
|
||||||
|
CfgManager.UpdateHistoryFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
CfgManager.WriteCrSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetTrackedReleaseDateUtc(CrBrowseEpisode episode){
|
||||||
|
DateTime episodeAirDate = episode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
|
||||||
|
? episode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
|
||||||
|
: episode.EpisodeMetadata.EpisodeAirDate;
|
||||||
|
|
||||||
|
DateTime premiumAvailableStart = episode.EpisodeMetadata.PremiumAvailableDate.Kind == DateTimeKind.Utc
|
||||||
|
? episode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime()
|
||||||
|
: episode.EpisodeMetadata.PremiumAvailableDate;
|
||||||
|
|
||||||
|
DateTime now = DateTime.Now;
|
||||||
|
DateTime oneYearFromNow = now.AddYears(1);
|
||||||
|
|
||||||
|
var targetDate = premiumAvailableStart;
|
||||||
|
|
||||||
|
if (targetDate >= oneYearFromNow){
|
||||||
|
DateTime freeAvailableStart = episode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
|
||||||
|
? episode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
|
||||||
|
: episode.EpisodeMetadata.FreeAvailableDate;
|
||||||
|
|
||||||
|
if (freeAvailableStart <= oneYearFromNow){
|
||||||
|
targetDate = freeAvailableStart;
|
||||||
|
} else{
|
||||||
|
targetDate = episodeAirDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDate.Kind == DateTimeKind.Utc ? targetDate : targetDate.ToUniversalTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetBackgroundImage(){
|
public void SetBackgroundImage(){
|
||||||
|
|
@ -224,6 +339,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
||||||
CrunchyrollManager.Instance.InitOptions();
|
CrunchyrollManager.Instance.InitOptions();
|
||||||
|
|
||||||
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
|
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
|
||||||
|
await Updater.Instance.CheckGhJsonAsync();
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
|
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
|
||||||
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
|
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,43 @@ using System.Collections.Specialized;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CRD.Downloader.Crunchyroll;
|
using CRD.Downloader.Crunchyroll;
|
||||||
using CRD.Utils;
|
using CRD.Utils;
|
||||||
using CRD.Utils.CustomList;
|
using CRD.Utils.CustomList;
|
||||||
|
using CRD.Utils.QueueManagement;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.History;
|
|
||||||
using CRD.ViewModels;
|
using CRD.ViewModels;
|
||||||
using CRD.Views;
|
|
||||||
using ReactiveUI;
|
|
||||||
|
|
||||||
namespace CRD.Downloader;
|
namespace CRD.Downloader;
|
||||||
|
|
||||||
public sealed partial class QueueManager : ObservableObject{
|
public sealed partial class QueueManager : ObservableObject{
|
||||||
|
|
||||||
public static QueueManager Instance{ get; } = new();
|
public static QueueManager Instance{ get; } = new();
|
||||||
|
|
||||||
#region Download Variables
|
#region Download Variables
|
||||||
|
|
||||||
public RefreshableObservableCollection<CrunchyEpMeta> Queue{ get; } = new();
|
private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = new();
|
||||||
public ObservableCollection<DownloadItemModel> DownloadItemModels{ get; } = new();
|
public ReadOnlyObservableCollection<CrunchyEpMeta> Queue{ get; }
|
||||||
|
|
||||||
|
private readonly DownloadItemModelCollection downloadItems = new();
|
||||||
|
|
||||||
|
public ObservableCollection<DownloadItemModel> DownloadItemModels => downloadItems.Items;
|
||||||
|
|
||||||
|
private readonly UiMutationQueue uiMutationQueue;
|
||||||
|
private readonly QueuePersistenceManager queuePersistenceManager;
|
||||||
|
|
||||||
|
private readonly object downloadStartLock = new();
|
||||||
|
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
|
||||||
|
|
||||||
|
private readonly ProcessingSlotManager processingSlots;
|
||||||
|
|
||||||
|
private int pumpScheduled;
|
||||||
|
private int pumpDirty;
|
||||||
|
private DateTimeOffset? autoDownloadBlockedUntilUtc;
|
||||||
|
private readonly object autoDownloadBlockLock = new();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public int ActiveDownloads{
|
public int ActiveDownloads{
|
||||||
get{
|
get{
|
||||||
|
|
@ -34,18 +51,7 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly object downloadStartLock = new();
|
public bool HasActiveDownloads => ActiveDownloads > 0;
|
||||||
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
|
|
||||||
private readonly object processingLock = new();
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim activeProcessingJobs;
|
|
||||||
private int processingJobsLimit;
|
|
||||||
private int borrowed;
|
|
||||||
|
|
||||||
private int pumpScheduled;
|
|
||||||
private int pumpDirty;
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool hasFailedItem;
|
private bool hasFailedItem;
|
||||||
|
|
@ -55,18 +61,43 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
private readonly CrunchyrollManager crunchyrollManager;
|
private readonly CrunchyrollManager crunchyrollManager;
|
||||||
|
|
||||||
public QueueManager(){
|
public QueueManager(){
|
||||||
this.crunchyrollManager = CrunchyrollManager.Instance;
|
crunchyrollManager = CrunchyrollManager.Instance;
|
||||||
|
|
||||||
activeProcessingJobs = new SemaphoreSlim(
|
uiMutationQueue = new UiMutationQueue();
|
||||||
initialCount: crunchyrollManager.CrunOptions.SimultaneousProcessingJobs,
|
queuePersistenceManager = new QueuePersistenceManager(this);
|
||||||
maxCount: 2);
|
Queue = new ReadOnlyObservableCollection<CrunchyEpMeta>(queue);
|
||||||
|
|
||||||
processingJobsLimit = crunchyrollManager.CrunOptions.SimultaneousProcessingJobs;
|
processingSlots = new ProcessingSlotManager(
|
||||||
|
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
|
||||||
|
|
||||||
Queue.CollectionChanged += UpdateItemListOnRemove;
|
queue.CollectionChanged += UpdateItemListOnRemove;
|
||||||
Queue.CollectionChanged += (_, _) => OnQueueStateChanged();
|
queue.CollectionChanged += (_, _) => OnQueueStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddToQueue(CrunchyEpMeta item){
|
||||||
|
uiMutationQueue.Enqueue(() => {
|
||||||
|
if (!queue.Contains(item))
|
||||||
|
queue.Add(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveFromQueue(CrunchyEpMeta item){
|
||||||
|
uiMutationQueue.Enqueue(() => {
|
||||||
|
int index = queue.IndexOf(item);
|
||||||
|
if (index >= 0)
|
||||||
|
queue.RemoveAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearQueue(){
|
||||||
|
uiMutationQueue.Enqueue(() => queue.Clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshQueue(){
|
||||||
|
uiMutationQueue.Enqueue(() => queue.Refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public bool TryStartDownload(DownloadItemModel model){
|
public bool TryStartDownload(DownloadItemModel model){
|
||||||
var item = model.epMeta;
|
var item = model.epMeta;
|
||||||
|
|
||||||
|
|
@ -74,13 +105,16 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
if (activeOrStarting.Contains(item))
|
if (activeOrStarting.Contains(item))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (item.DownloadProgress is{ IsDownloading: true })
|
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (item.DownloadProgress is{ Done: true })
|
if (item.DownloadProgress.IsDone)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (item.DownloadProgress is{ Error: true })
|
if (item.DownloadProgress.IsError)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (item.DownloadProgress.IsPaused)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
|
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
|
||||||
|
|
@ -89,11 +123,31 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
activeOrStarting.Add(item);
|
activeOrStarting.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NotifyDownloadStateChanged();
|
||||||
OnQueueStateChanged();
|
OnQueueStateChanged();
|
||||||
_ = model.StartDownloadCore();
|
_ = model.StartDownloadCore();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryResumeDownload(CrunchyEpMeta item){
|
||||||
|
lock (downloadStartLock){
|
||||||
|
if (activeOrStarting.Contains(item))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!item.DownloadProgress.IsPaused)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
activeOrStarting.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyDownloadStateChanged();
|
||||||
|
OnQueueStateChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public void ReleaseDownloadSlot(CrunchyEpMeta item){
|
public void ReleaseDownloadSlot(CrunchyEpMeta item){
|
||||||
bool removed;
|
bool removed;
|
||||||
|
|
||||||
|
|
@ -102,110 +156,98 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removed){
|
if (removed){
|
||||||
|
NotifyDownloadStateChanged();
|
||||||
OnQueueStateChanged();
|
OnQueueStateChanged();
|
||||||
|
|
||||||
|
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||||
RequestPump();
|
RequestPump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
|
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
|
||||||
return activeProcessingJobs.WaitAsync(cancellationToken);
|
return processingSlots.WaitAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ReleaseProcessingSlot(){
|
public void ReleaseProcessingSlot(){
|
||||||
lock (processingLock){
|
processingSlots.Release();
|
||||||
if (borrowed > 0){
|
|
||||||
borrowed--;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
activeProcessingJobs.Release();
|
public void SetProcessingLimit(int newLimit){
|
||||||
}
|
processingSlots.SetLimit(newLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetLimit(int newLimit){
|
public void RestorePersistedQueue(){
|
||||||
if (newLimit < 0)
|
queuePersistenceManager.RestoreQueue();
|
||||||
throw new ArgumentOutOfRangeException(nameof(newLimit));
|
|
||||||
|
|
||||||
lock (processingLock){
|
|
||||||
if (newLimit == processingJobsLimit)
|
|
||||||
return;
|
|
||||||
|
|
||||||
int delta = newLimit - processingJobsLimit;
|
|
||||||
|
|
||||||
if (delta > 0){
|
|
||||||
int giveBack = Math.Min(borrowed, delta);
|
|
||||||
borrowed -= giveBack;
|
|
||||||
|
|
||||||
int toRelease = delta - giveBack;
|
|
||||||
if (toRelease > 0)
|
|
||||||
activeProcessingJobs.Release(toRelease);
|
|
||||||
} else{
|
|
||||||
int toRemove = -delta;
|
|
||||||
|
|
||||||
|
|
||||||
while (toRemove > 0 && activeProcessingJobs.Wait(0)){
|
|
||||||
toRemove--;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SaveQueueSnapshot(){
|
||||||
borrowed += toRemove;
|
queuePersistenceManager.SaveNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
processingJobsLimit = newLimit;
|
internal List<CrunchyEpMeta> GetQueueSnapshot(){
|
||||||
|
if (Dispatcher.UIThread.CheckAccess()){
|
||||||
|
return queue.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Dispatcher.UIThread
|
||||||
|
.InvokeAsync(() => queue.ToList())
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> items){
|
||||||
|
uiMutationQueue.Enqueue(() => {
|
||||||
|
queue.Clear();
|
||||||
|
foreach (var item in items){
|
||||||
|
if (!queue.Contains(item))
|
||||||
|
queue.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
RestoreRetryStateFromQueue();
|
||||||
|
UpdateDownloadListItems();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
|
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
|
||||||
if (e.Action == NotifyCollectionChangedAction.Remove){
|
if (e.Action == NotifyCollectionChangedAction.Remove){
|
||||||
if (e.OldItems != null)
|
if (e.OldItems != null){
|
||||||
foreach (var eOldItem in e.OldItems){
|
foreach (var oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
|
||||||
var downloadItem = DownloadItemModels.FirstOrDefault(downloadItem => downloadItem.epMeta.Equals(eOldItem));
|
downloadItems.Remove(oldItem);
|
||||||
if (downloadItem != null){
|
|
||||||
DownloadItemModels.Remove(downloadItem);
|
|
||||||
} else{
|
|
||||||
Console.Error.WriteLine("Failed to Remove Episode from list");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (e.Action == NotifyCollectionChangedAction.Reset && Queue.Count == 0){
|
} else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
|
||||||
DownloadItemModels.Clear();
|
downloadItems.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateDownloadListItems();
|
UpdateDownloadListItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
|
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
|
||||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => {
|
uiMutationQueue.Enqueue(() => {
|
||||||
if (removeFromQueue){
|
if (removeFromQueue){
|
||||||
if (Queue.Contains(item))
|
int index = queue.IndexOf(item);
|
||||||
Queue.Remove(item);
|
if (index >= 0)
|
||||||
|
queue.RemoveAt(index);
|
||||||
} else{
|
} else{
|
||||||
Queue.Refresh();
|
queue.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
OnQueueStateChanged();
|
OnQueueStateChanged();
|
||||||
}, Avalonia.Threading.DispatcherPriority.Background);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateDownloadListItems(){
|
public void UpdateDownloadListItems(){
|
||||||
foreach (CrunchyEpMeta crunchyEpMeta in Queue.ToList()){
|
downloadItems.SyncFromQueue(queue);
|
||||||
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
|
|
||||||
if (downloadItem != null){
|
|
||||||
downloadItem.Refresh();
|
|
||||||
} else{
|
|
||||||
downloadItem = new DownloadItemModel(crunchyEpMeta);
|
|
||||||
_ = downloadItem.LoadImage();
|
|
||||||
DownloadItemModels.Add(downloadItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
|
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
|
||||||
|
|
||||||
if (crunchyrollManager.CrunOptions.AutoDownload){
|
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||||
RequestPump();
|
RequestPump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RequestPump(){
|
private void RequestPump(){
|
||||||
Interlocked.Exchange(ref pumpDirty, 1);
|
Interlocked.Exchange(ref pumpDirty, 1);
|
||||||
|
|
||||||
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
|
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
|
||||||
|
|
@ -234,7 +276,26 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PumpQueue(){
|
private void PumpQueue(){
|
||||||
|
if (!crunchyrollManager.CrunOptions.AutoDownload)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lock (autoDownloadBlockLock){
|
||||||
|
if (autoDownloadBlockedUntilUtc.HasValue && !HasPendingRetryItems()){
|
||||||
|
autoDownloadBlockedUntilUtc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value > DateTimeOffset.UtcNow){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoDownloadBlockedUntilUtc.HasValue){
|
||||||
|
autoDownloadBlockedUntilUtc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<CrunchyEpMeta> toStart = new();
|
List<CrunchyEpMeta> toStart = new();
|
||||||
|
List<CrunchyEpMeta> toResume = new();
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
lock (downloadStartLock){
|
lock (downloadStartLock){
|
||||||
int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads;
|
int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads;
|
||||||
|
|
@ -243,17 +304,20 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
if (freeSlots == 0)
|
if (freeSlots == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var item in Queue.ToList()){
|
foreach (var item in queue.ToList()){
|
||||||
if (freeSlots == 0)
|
if (freeSlots == 0)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (item.DownloadProgress.Error)
|
if (item.DownloadProgress.IsError)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (item.DownloadProgress.Done)
|
if (item.DownloadProgress.IsWaitingForRetry)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (item.DownloadProgress.IsDownloading)
|
if (item.DownloadProgress.IsDone)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (activeOrStarting.Contains(item))
|
if (activeOrStarting.Contains(item))
|
||||||
|
|
@ -261,12 +325,29 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
|
|
||||||
activeOrStarting.Add(item);
|
activeOrStarting.Add(item);
|
||||||
freeSlots--;
|
freeSlots--;
|
||||||
|
|
||||||
|
if (item.DownloadProgress.IsPaused){
|
||||||
|
toResume.Add(item);
|
||||||
|
} else{
|
||||||
toStart.Add(item);
|
toStart.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed){
|
||||||
|
NotifyDownloadStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in toResume){
|
||||||
|
item.DownloadProgress.State = item.DownloadProgress.ResumeState;
|
||||||
|
var model = downloadItems.Find(item);
|
||||||
|
model?.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in toStart){
|
foreach (var item in toStart){
|
||||||
var model = DownloadItemModels.FirstOrDefault(x => x.epMeta.Equals(item));
|
var model = downloadItems.Find(item);
|
||||||
if (model != null){
|
if (model != null){
|
||||||
_ = model.StartDownloadCore();
|
_ = model.StartDownloadCore();
|
||||||
} else{
|
} else{
|
||||||
|
|
@ -277,431 +358,110 @@ public sealed partial class QueueManager : ObservableObject{
|
||||||
OnQueueStateChanged();
|
OnQueueStateChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void BlockAutoDownloadUntil(TimeSpan delay, CancellationToken cancellationToken = default){
|
||||||
|
DateTimeOffset unblockAt = DateTimeOffset.UtcNow.Add(delay);
|
||||||
|
|
||||||
|
lock (autoDownloadBlockLock){
|
||||||
|
if (!autoDownloadBlockedUntilUtc.HasValue || unblockAt > autoDownloadBlockedUntilUtc.Value){
|
||||||
|
autoDownloadBlockedUntilUtc = unblockAt;
|
||||||
|
} else{
|
||||||
|
unblockAt = autoDownloadBlockedUntilUtc.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () => {
|
||||||
|
try{
|
||||||
|
var remaining = unblockAt - DateTimeOffset.UtcNow;
|
||||||
|
if (remaining > TimeSpan.Zero){
|
||||||
|
await Task.Delay(remaining, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (autoDownloadBlockLock){
|
||||||
|
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value <= DateTimeOffset.UtcNow){
|
||||||
|
autoDownloadBlockedUntilUtc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshQueue();
|
||||||
|
UpdateDownloadListItems();
|
||||||
|
} catch (OperationCanceledException){
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ScheduleRetry(CrunchyEpMeta item, TimeSpan delay, string statusText, CancellationToken cancellationToken = default){
|
||||||
|
item.DownloadProgress.ScheduleRetry(delay, statusText);
|
||||||
|
RefreshQueue();
|
||||||
|
OnQueueStateChanged();
|
||||||
|
|
||||||
|
ScheduleRetryWake(item, item.DownloadProgress.RetryAtUtc, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreRetryStateFromQueue(){
|
||||||
|
var retryItems = queue
|
||||||
|
.Where(item => item.DownloadProgress.IsWaitingForRetry)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (retryItems.Count == 0){
|
||||||
|
lock (autoDownloadBlockLock){
|
||||||
|
autoDownloadBlockedUntilUtc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxRetryAt = retryItems
|
||||||
|
.Select(item => item.DownloadProgress.RetryAtUtc)
|
||||||
|
.OfType<DateTimeOffset>()
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
lock (autoDownloadBlockLock){
|
||||||
|
autoDownloadBlockedUntilUtc = maxRetryAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var retryItem in retryItems){
|
||||||
|
ScheduleRetryWake(retryItem, retryItem.DownloadProgress.RetryAtUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasPendingRetryItems(){
|
||||||
|
return queue.Any(item => item.DownloadProgress.IsWaitingForRetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScheduleRetryWake(CrunchyEpMeta item, DateTimeOffset? retryAtUtc, CancellationToken cancellationToken = default){
|
||||||
|
if (!retryAtUtc.HasValue){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () => {
|
||||||
|
try{
|
||||||
|
var remaining = retryAtUtc.Value - DateTimeOffset.UtcNow;
|
||||||
|
if (remaining > TimeSpan.Zero){
|
||||||
|
await Task.Delay(remaining, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.DownloadProgress.RetryAtUtc = null;
|
||||||
|
RefreshQueue();
|
||||||
|
UpdateDownloadListItems();
|
||||||
|
} catch (OperationCanceledException){
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void OnQueueStateChanged(){
|
private void OnQueueStateChanged(){
|
||||||
QueueStateChanged?.Invoke(this, EventArgs.Empty);
|
QueueStateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void NotifyDownloadStateChanged(){
|
||||||
public async Task CrAddEpisodeToQueue(string epId, string crLocale, List<string> dubLang, bool updateHistory = false, EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default){
|
OnPropertyChanged(nameof(ActiveDownloads));
|
||||||
if (string.IsNullOrEmpty(epId)){
|
OnPropertyChanged(nameof(HasActiveDownloads));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
|
||||||
|
|
||||||
var episodeL = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(epId, crLocale);
|
|
||||||
|
|
||||||
|
|
||||||
if (episodeL != null){
|
|
||||||
if (episodeL.IsPremiumOnly && !CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.HasPremium){
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode – make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episodeL, updateHistory);
|
|
||||||
|
|
||||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
|
||||||
var variant = sList.EpisodeAndLanguages.Variants.First();
|
|
||||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
|
|
||||||
if (historyEpisode.dublist.Count > 0){
|
|
||||||
dubLang = historyEpisode.dublist;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
var selected = CrunchyrollManager.Instance.CrEpisode.EpisodeMeta(sList, dubLang);
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
|
|
||||||
if (selected.Data is{ Count: > 0 }){
|
|
||||||
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(selected.Data.First().MediaId,
|
|
||||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
|
|
||||||
selected.Description = episode?.Description ?? selected.Description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.Data is{ Count: > 0 }){
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
|
||||||
// var historyEpisode = CrHistory.GetHistoryEpisodeWithDownloadDir(selected.ShowId, selected.SeasonId, selected.Data.First().MediaId);
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
|
|
||||||
if (historyEpisode.historyEpisode != null){
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
|
|
||||||
selected.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
|
|
||||||
selected.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
|
|
||||||
selected.DownloadPath = historyEpisode.downloadDirPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selected.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
|
||||||
|
|
||||||
selected.DownloadSubs = historyEpisode.sublist.Count > 0 ? historyEpisode.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
|
||||||
|
|
||||||
selected.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && selected.Data.Count > 1){
|
|
||||||
var sortedMetaData = selected.Data
|
|
||||||
.OrderBy(metaData => {
|
|
||||||
var locale = metaData.Lang?.CrLocale ?? string.Empty;
|
|
||||||
var index = dubLang.IndexOf(locale);
|
|
||||||
return index != -1 ? index : int.MaxValue;
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (sortedMetaData.Count != 0){
|
|
||||||
var first = sortedMetaData.First();
|
|
||||||
selected.Data = [first];
|
|
||||||
selected.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
|
|
||||||
if (newOptions == null){
|
|
||||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (episodeDownloadMode){
|
|
||||||
case EpisodeDownloadMode.OnlyVideo:
|
|
||||||
newOptions.Novids = false;
|
|
||||||
newOptions.Noaudio = true;
|
|
||||||
selected.DownloadSubs = ["none"];
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.OnlyAudio:
|
|
||||||
newOptions.Novids = true;
|
|
||||||
newOptions.Noaudio = false;
|
|
||||||
selected.DownloadSubs = ["none"];
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.OnlySubs:
|
|
||||||
newOptions.Novids = true;
|
|
||||||
newOptions.Noaudio = true;
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.Default:
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
|
|
||||||
if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
|
|
||||||
selected.HighlightAllAvailable = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
|
||||||
if (!selected.DownloadSubs.Contains("none") && !selected.DownloadSubs.Contains("all") && !selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
|
|
||||||
//missing subs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing subs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
|
||||||
//missing dubs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newOptions.DubLang = dubLang;
|
|
||||||
|
|
||||||
selected.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
Queue.Add(selected);
|
|
||||||
|
|
||||||
|
|
||||||
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
|
||||||
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
|
|
||||||
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
|
|
||||||
|
|
||||||
var languages = sList.EpisodeAndLanguages.Variants
|
|
||||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
|
|
||||||
} else{
|
|
||||||
Console.WriteLine("Added Episode to Queue");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
|
|
||||||
}
|
|
||||||
} else{
|
|
||||||
Console.WriteLine("Episode couldn't be added to Queue");
|
|
||||||
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
|
|
||||||
|
|
||||||
var languages = sList.EpisodeAndLanguages.Variants
|
|
||||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
|
|
||||||
if (!CrunchyrollManager.Instance.CrunOptions.DownloadOnlyWithAllSelectedDubSub){
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Couldn't find episode trying to find movie with id");
|
|
||||||
|
|
||||||
var movie = await CrunchyrollManager.Instance.CrMovies.ParseMovieById(epId, crLocale);
|
|
||||||
|
|
||||||
if (movie != null){
|
|
||||||
var movieMeta = CrunchyrollManager.Instance.CrMovies.EpisodeMeta(movie, dubLang);
|
|
||||||
|
|
||||||
if (movieMeta != null){
|
|
||||||
movieMeta.DownloadSubs = CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
|
||||||
movieMeta.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
|
|
||||||
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
|
|
||||||
if (newOptions == null){
|
|
||||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (episodeDownloadMode){
|
|
||||||
case EpisodeDownloadMode.OnlyVideo:
|
|
||||||
newOptions.Novids = false;
|
|
||||||
newOptions.Noaudio = true;
|
|
||||||
movieMeta.DownloadSubs = ["none"];
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.OnlyAudio:
|
|
||||||
newOptions.Novids = true;
|
|
||||||
newOptions.Noaudio = false;
|
|
||||||
movieMeta.DownloadSubs = ["none"];
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.OnlySubs:
|
|
||||||
newOptions.Novids = true;
|
|
||||||
newOptions.Noaudio = true;
|
|
||||||
break;
|
|
||||||
case EpisodeDownloadMode.Default:
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
newOptions.DubLang = dubLang;
|
|
||||||
|
|
||||||
movieMeta.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
|
||||||
|
|
||||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
|
||||||
if (!movieMeta.DownloadSubs.Contains("none") && !movieMeta.DownloadSubs.Contains("all") && !movieMeta.DownloadSubs.All(item => (movieMeta.AvailableSubs ?? []).Contains(item))){
|
|
||||||
//missing subs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing subs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (movieMeta.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
|
||||||
//missing dubs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Queue.Add(movieMeta);
|
|
||||||
|
|
||||||
Console.WriteLine("Added Movie to Queue");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added Movie to Queue", ToastType.Information, 1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine($"No episode or movie found with the id: {epId}");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue - No episode or movie found with the id: {epId}", ToastType.Error, 3));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public void CrAddMusicMetaToQueue(CrunchyEpMeta epMeta){
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
epMeta.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
Queue.Add(epMeta);
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
|
|
||||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
|
||||||
|
|
||||||
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
|
|
||||||
|
|
||||||
if (musicVideo != null){
|
|
||||||
var musicVideoMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(musicVideo);
|
|
||||||
|
|
||||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
|
||||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
|
||||||
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
|
||||||
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
musicVideoMeta.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
Queue.Add(musicVideoMeta);
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added music video to the queue", ToastType.Information, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
|
|
||||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
|
||||||
|
|
||||||
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
|
|
||||||
|
|
||||||
if (concert != null){
|
|
||||||
var concertMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(concert);
|
|
||||||
|
|
||||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
|
||||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
|
||||||
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
|
||||||
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
concertMeta.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
Queue.Add(concertMeta);
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added concert to the queue", ToastType.Information, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
|
|
||||||
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
|
|
||||||
|
|
||||||
var failed = false;
|
|
||||||
var partialAdd = false;
|
|
||||||
|
|
||||||
|
|
||||||
foreach (var crunchyEpMeta in selected.Values.ToList()){
|
|
||||||
if (crunchyEpMeta.Data.FirstOrDefault() != null){
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
|
||||||
var historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDownloadDir(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId, crunchyEpMeta.Data.First().MediaId);
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
|
|
||||||
if (historyEpisode.historyEpisode != null){
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
|
|
||||||
crunchyEpMeta.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
|
|
||||||
crunchyEpMeta.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
|
|
||||||
crunchyEpMeta.DownloadPath = historyEpisode.downloadDirPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
|
|
||||||
if (crunchyEpMeta.Data is{ Count: > 0 }){
|
|
||||||
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(crunchyEpMeta.Data.First().MediaId,
|
|
||||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
|
|
||||||
crunchyEpMeta.Description = episode?.Description ?? crunchyEpMeta.Description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var subLangList = CrunchyrollManager.Instance.History.GetSubList(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId);
|
|
||||||
|
|
||||||
crunchyEpMeta.VideoQuality = !string.IsNullOrEmpty(subLangList.videoQuality) ? subLangList.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
|
||||||
crunchyEpMeta.DownloadSubs = subLangList.sublist.Count > 0 ? subLangList.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
|
||||||
|
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && crunchyEpMeta.Data.Count > 1){
|
|
||||||
var sortedMetaData = crunchyEpMeta.Data
|
|
||||||
.OrderBy(metaData => {
|
|
||||||
var locale = metaData.Lang?.CrLocale ?? string.Empty;
|
|
||||||
var index = data.DubLang.IndexOf(locale);
|
|
||||||
return index != -1 ? index : int.MaxValue;
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (sortedMetaData.Count != 0){
|
|
||||||
var first = sortedMetaData.First();
|
|
||||||
crunchyEpMeta.Data = [first];
|
|
||||||
crunchyEpMeta.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
|
||||||
|
|
||||||
if (newOptions == null){
|
|
||||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crunchyEpMeta.OnlySubs){
|
|
||||||
newOptions.Novids = true;
|
|
||||||
newOptions.Noaudio = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
newOptions.DubLang = data.DubLang;
|
|
||||||
|
|
||||||
crunchyEpMeta.DownloadSettings = newOptions;
|
|
||||||
|
|
||||||
if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
|
|
||||||
if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
|
|
||||||
crunchyEpMeta.HighlightAllAvailable = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
|
||||||
if (!crunchyEpMeta.DownloadSubs.Contains("none") && !crunchyEpMeta.DownloadSubs.Contains("all") && !crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
|
|
||||||
//missing subs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing subs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
|
||||||
//missing dubs
|
|
||||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Queue.Add(crunchyEpMeta);
|
|
||||||
|
|
||||||
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
|
||||||
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
|
|
||||||
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
|
|
||||||
|
|
||||||
partialAdd = true;
|
|
||||||
|
|
||||||
var languages = (crunchyEpMeta.Data.First().Versions ?? []).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray();
|
|
||||||
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"{crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", crunchyEpMeta.AvailableSubs ?? [])}]");
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
|
|
||||||
}
|
|
||||||
} else{
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failed && !partialAdd){
|
|
||||||
MainWindow.Instance.ShowError("Not all episodes could be added – make sure that you are signed in with an account that has an active premium subscription?");
|
|
||||||
} else if (selected.Values.Count > 0 && !partialAdd){
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1));
|
|
||||||
} else if (!partialAdd){
|
|
||||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,29 +1,178 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using NetCoreAudio;
|
using NetCoreAudio;
|
||||||
|
using NAudio.Wave;
|
||||||
|
|
||||||
namespace CRD.Utils;
|
namespace CRD.Utils;
|
||||||
|
|
||||||
public class AudioPlayer{
|
public class AudioPlayer{
|
||||||
private readonly Player _player;
|
private readonly Player _player;
|
||||||
private bool _isPlaying = false;
|
private bool _isPlaying;
|
||||||
|
private WaveOutEvent? _waveOut;
|
||||||
|
private AudioFileReader? _audioFileReader;
|
||||||
|
private TaskCompletionSource? _playbackCompleted;
|
||||||
|
|
||||||
public AudioPlayer(){
|
public AudioPlayer(){
|
||||||
_player = new Player();
|
_player = new Player();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void Play(string path){
|
public static (bool IsValid, string ErrorMessage) ValidateSoundFile(string path){
|
||||||
|
if (string.IsNullOrWhiteSpace(path)){
|
||||||
|
return (false, "The selected sound file path is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(path)){
|
||||||
|
return (false, "The selected sound file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try{
|
||||||
|
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
if (stream.Length == 0){
|
||||||
|
return (false, "The selected sound file is empty.");
|
||||||
|
}
|
||||||
|
} catch (Exception exception){
|
||||||
|
return (false, $"The selected sound file could not be opened: {exception.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Path.GetExtension(path))){
|
||||||
|
return (false, "The selected sound file has no file extension.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool IsSuccess, string ErrorMessage)> ValidatePlaybackAsync(string path){
|
||||||
|
var fileValidation = ValidateSoundFile(path);
|
||||||
|
if (!fileValidation.IsValid){
|
||||||
|
return (false, fileValidation.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isPlaying){
|
||||||
|
return (false, "Audio playback is already in progress.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows()){
|
||||||
|
try{
|
||||||
|
_isPlaying = true;
|
||||||
|
DisposeWindowsPlayback();
|
||||||
|
|
||||||
|
_audioFileReader = new AudioFileReader(path);
|
||||||
|
_waveOut = new WaveOutEvent();
|
||||||
|
_playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
_waveOut.PlaybackStopped += (_, args) => {
|
||||||
|
if (args.Exception != null){
|
||||||
|
_playbackCompleted?.TrySetException(args.Exception);
|
||||||
|
} else{
|
||||||
|
_playbackCompleted?.TrySetResult();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_waveOut.Init(_audioFileReader);
|
||||||
|
_waveOut.Play();
|
||||||
|
await _playbackCompleted.Task;
|
||||||
|
return (true, string.Empty);
|
||||||
|
} catch (Exception exception){
|
||||||
|
return (false, exception.Message);
|
||||||
|
} finally{
|
||||||
|
DisposeWindowsPlayback();
|
||||||
|
_isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try{
|
||||||
|
_isPlaying = true;
|
||||||
|
await _player.Play(path);
|
||||||
|
return (true, string.Empty);
|
||||||
|
} catch (Exception exception){
|
||||||
|
return (false, exception.Message);
|
||||||
|
} finally{
|
||||||
|
_isPlaying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayAsync(string path){
|
||||||
|
var fileValidation = ValidateSoundFile(path);
|
||||||
|
if (!fileValidation.IsValid){
|
||||||
|
Console.Error.WriteLine($"Failed to play audio '{path}': {fileValidation.ErrorMessage}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_isPlaying){
|
if (_isPlaying){
|
||||||
Console.WriteLine("Audio is already playing, ignoring duplicate request.");
|
Console.WriteLine("Audio is already playing, ignoring duplicate request.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows()){
|
||||||
|
try{
|
||||||
_isPlaying = true;
|
_isPlaying = true;
|
||||||
await _player.Play(path);
|
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;
|
_isPlaying = false;
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public async void Stop(){
|
try{
|
||||||
await _player.Stop();
|
_isPlaying = true;
|
||||||
|
await _player.Play(path);
|
||||||
|
} catch (Exception exception){
|
||||||
|
Console.Error.WriteLine($"Failed to play audio '{path}': {exception.Message}");
|
||||||
|
} finally{
|
||||||
_isPlaying = false;
|
_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ public class Widevine{
|
||||||
private byte[] privateKey = new byte[0];
|
private byte[] privateKey = new byte[0];
|
||||||
private byte[] identifierBlob = new byte[0];
|
private byte[] identifierBlob = new byte[0];
|
||||||
|
|
||||||
public bool canDecrypt = false;
|
public bool canDecrypt;
|
||||||
|
|
||||||
|
|
||||||
#region Singelton
|
#region Singelton
|
||||||
|
|
@ -114,7 +114,7 @@ public class Widevine{
|
||||||
|
|
||||||
// var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
|
// var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
|
||||||
|
|
||||||
var response = (IsOk: false, ResponseContent: "", error: "");
|
var response = (IsOk: false, ResponseContent: "", error: "",Headers: new Dictionary<string, string>());
|
||||||
for (var attempt = 0; attempt < 3 + 1; attempt++){
|
for (var attempt = 0; attempt < 3 + 1; attempt++){
|
||||||
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
|
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
|
||||||
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ public enum SeriesType{
|
||||||
Artist,
|
Artist,
|
||||||
[EnumMember(Value = "Series")]
|
[EnumMember(Value = "Series")]
|
||||||
Series,
|
Series,
|
||||||
|
[EnumMember(Value = "Movie")]
|
||||||
|
Movie,
|
||||||
[EnumMember(Value = "Unknown")]
|
[EnumMember(Value = "Unknown")]
|
||||||
Unknown
|
Unknown
|
||||||
}
|
}
|
||||||
|
|
@ -276,6 +278,15 @@ public enum EpisodeDownloadMode{
|
||||||
OnlySubs,
|
OnlySubs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum DownloadState{
|
||||||
|
Queued,
|
||||||
|
Downloading,
|
||||||
|
Paused,
|
||||||
|
Processing,
|
||||||
|
Done,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
public enum HistoryRefreshMode{
|
public enum HistoryRefreshMode{
|
||||||
DefaultAll = 0,
|
DefaultAll = 0,
|
||||||
DefaultActive = 1,
|
DefaultActive = 1,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ public class CfgManager{
|
||||||
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
|
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
|
||||||
|
|
||||||
public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json");
|
public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json");
|
||||||
|
public static readonly string PathCrQueue = Path.Combine(workingDirectory, "config", "queue.json");
|
||||||
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
|
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
|
||||||
|
|
||||||
private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
|
private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
|
||||||
|
|
@ -44,7 +45,7 @@ public class CfgManager{
|
||||||
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
|
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
|
||||||
|
|
||||||
private static StreamWriter logFile;
|
private static StreamWriter logFile;
|
||||||
private static bool isLogModeEnabled = false;
|
private static bool isLogModeEnabled;
|
||||||
|
|
||||||
static CfgManager(){
|
static CfgManager(){
|
||||||
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
|
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
|
||||||
|
|
@ -366,4 +367,14 @@ public class CfgManager{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void DeleteFileIfExists(string pathToFile){
|
||||||
|
try{
|
||||||
|
if (File.Exists(pathToFile)){
|
||||||
|
File.Delete(pathToFile);
|
||||||
|
}
|
||||||
|
} catch (Exception ex){
|
||||||
|
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ using Newtonsoft.Json;
|
||||||
namespace CRD.Utils.HLS;
|
namespace CRD.Utils.HLS;
|
||||||
|
|
||||||
public class HlsDownloader{
|
public class HlsDownloader{
|
||||||
|
private readonly CancellationToken _cancellationToken;
|
||||||
private Data _data = new();
|
private Data _data = new();
|
||||||
|
|
||||||
private CrunchyEpMeta _currentEpMeta;
|
private CrunchyEpMeta _currentEpMeta;
|
||||||
|
|
@ -24,12 +25,24 @@ public class HlsDownloader{
|
||||||
private bool _isAudio;
|
private bool _isAudio;
|
||||||
private bool _newDownloadMethode;
|
private bool _newDownloadMethode;
|
||||||
|
|
||||||
|
private async Task WaitWhilePausedAsync(CancellationToken cancellationToken){
|
||||||
|
while (_currentEpMeta.DownloadProgress.IsPaused){
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
throw new OperationCanceledException(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(500, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio, bool newDownloadMethode){
|
public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio, bool newDownloadMethode){
|
||||||
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
|
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
|
||||||
throw new Exception("Playlist is empty");
|
throw new Exception("Playlist is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentEpMeta = meta;
|
_currentEpMeta = meta;
|
||||||
|
_cancellationToken = meta.Cts.Token;
|
||||||
|
|
||||||
_isVideo = isVideo;
|
_isVideo = isVideo;
|
||||||
_isAudio = isAudio;
|
_isAudio = isAudio;
|
||||||
|
|
@ -62,6 +75,7 @@ public class HlsDownloader{
|
||||||
|
|
||||||
|
|
||||||
public async Task<(bool Ok, PartsData Parts)> Download(){
|
public async Task<(bool Ok, PartsData Parts)> Download(){
|
||||||
|
_cancellationToken.ThrowIfCancellationRequested();
|
||||||
string fn = _data.OutputFile ?? string.Empty;
|
string fn = _data.OutputFile ?? string.Empty;
|
||||||
|
|
||||||
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
|
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
|
||||||
|
|
@ -141,8 +155,8 @@ public class HlsDownloader{
|
||||||
|
|
||||||
try{
|
try{
|
||||||
var initDl = await DownloadPart(initSeg, 0, 0);
|
var initDl = await DownloadPart(initSeg, 0, 0);
|
||||||
await File.WriteAllBytesAsync(fn, initDl);
|
await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
|
||||||
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }));
|
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }), _cancellationToken);
|
||||||
Console.WriteLine("Init part downloaded.");
|
Console.WriteLine("Init part downloaded.");
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
|
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
|
||||||
|
|
@ -185,7 +199,7 @@ public class HlsDownloader{
|
||||||
}
|
}
|
||||||
|
|
||||||
try{
|
try{
|
||||||
await Task.WhenAll(keyTasks.Values);
|
await Task.WhenAll(keyTasks.Values).WaitAsync(_cancellationToken);
|
||||||
} catch (Exception ex){
|
} catch (Exception ex){
|
||||||
Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
|
Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
|
||||||
throw;
|
throw;
|
||||||
|
|
@ -200,7 +214,7 @@ public class HlsDownloader{
|
||||||
}
|
}
|
||||||
|
|
||||||
while (partTasks.Count > 0){
|
while (partTasks.Count > 0){
|
||||||
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values);
|
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values).WaitAsync(_cancellationToken);
|
||||||
int completedIndex = -1;
|
int completedIndex = -1;
|
||||||
foreach (var task in partTasks){
|
foreach (var task in partTasks){
|
||||||
if (task.Value == completedTask){
|
if (task.Value == completedTask){
|
||||||
|
|
@ -234,7 +248,7 @@ public class HlsDownloader{
|
||||||
while (attempt < 3 && !writeSuccess){
|
while (attempt < 3 && !writeSuccess){
|
||||||
try{
|
try{
|
||||||
using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
||||||
await stream.WriteAsync(part, 0, part.Length);
|
await stream.WriteAsync(part, 0, part.Length, _cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeSuccess = true;
|
writeSuccess = true;
|
||||||
|
|
@ -242,7 +256,7 @@ public class HlsDownloader{
|
||||||
Console.Error.WriteLine(ex);
|
Console.Error.WriteLine(ex);
|
||||||
Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)");
|
Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)");
|
||||||
Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying");
|
Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying");
|
||||||
await Task.Delay(_data.WaitTime);
|
await Task.Delay(_data.WaitTime, _cancellationToken);
|
||||||
attempt++;
|
attempt++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,11 +282,16 @@ public class HlsDownloader{
|
||||||
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||||
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||||
|
|
||||||
|
await WaitWhilePausedAsync(_cancellationToken);
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
return (Ok: false, _data.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
// Log progress
|
// Log progress
|
||||||
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})");
|
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})");
|
||||||
|
|
||||||
_currentEpMeta.DownloadProgress = new DownloadProgress(){
|
_currentEpMeta.DownloadProgress = new DownloadProgress(){
|
||||||
IsDownloading = true,
|
State = DownloadState.Downloading,
|
||||||
Percent = dataLog.Percent,
|
Percent = dataLog.Percent,
|
||||||
Time = dataLog.Time,
|
Time = dataLog.Time,
|
||||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||||
|
|
@ -280,7 +299,7 @@ public class HlsDownloader{
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
if (!_currentEpMeta.DownloadProgress.Done){
|
if (!_currentEpMeta.DownloadProgress.IsDone){
|
||||||
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
|
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
|
||||||
try{
|
try{
|
||||||
if (File.Exists(downloadItemDownloadedFile)){
|
if (File.Exists(downloadItemDownloadedFile)){
|
||||||
|
|
@ -295,26 +314,124 @@ public class HlsDownloader{
|
||||||
return (Ok: false, _data.Parts);
|
return (Ok: false, _data.Parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
while (_currentEpMeta.Paused){
|
await WaitWhilePausedAsync(_cancellationToken);
|
||||||
await Task.Delay(500);
|
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
return (Ok: false, _data.Parts);
|
return (Ok: false, _data.Parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (Ok: true, _data.Parts);
|
return (Ok: true, _data.Parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly object _resumeLock = new object();
|
private static readonly object _resumeLock = new object();
|
||||||
|
|
||||||
|
private void CleanupDownloadedFiles(){
|
||||||
|
if (_currentEpMeta.DownloadProgress.IsDone){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in _currentEpMeta.downloadedFiles){
|
||||||
|
try{
|
||||||
|
if (File.Exists(file)){
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
} catch (Exception ex){
|
||||||
|
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadBufferedSegmentAsync(int index, List<dynamic> segments, string tempDir, string resumeFile, int totalSeg, int mergedParts, SemaphoreSlim semaphore,
|
||||||
|
CancellationTokenSource cancellationSource, CancellationToken token, Action markError, Func<long> getLastUiUpdate, Action<long> setLastUiUpdate){
|
||||||
|
try{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
await WaitWhilePausedAsync(token);
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var segment = new Segment{
|
||||||
|
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
|
||||||
|
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
|
||||||
|
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
|
||||||
|
};
|
||||||
|
|
||||||
|
var data = await DownloadPart(segment, index, _data.Offset);
|
||||||
|
|
||||||
|
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
|
||||||
|
await File.WriteAllBytesAsync(tempFile, data, token);
|
||||||
|
|
||||||
|
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
|
||||||
|
lock (_resumeLock){
|
||||||
|
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
|
||||||
|
DownloadedParts = currentDownloaded,
|
||||||
|
MergedParts = mergedParts,
|
||||||
|
Total = totalSeg
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
long lastUiUpdate = getLastUiUpdate();
|
||||||
|
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - lastUiUpdate > 500){
|
||||||
|
var dataLog = GetDownloadInfo(
|
||||||
|
lastUiUpdate,
|
||||||
|
currentDownloaded,
|
||||||
|
totalSeg,
|
||||||
|
_data.BytesDownloaded,
|
||||||
|
_data.TotalBytes
|
||||||
|
);
|
||||||
|
|
||||||
|
_data.BytesDownloaded = 0;
|
||||||
|
setLastUiUpdate(DateTimeOffset.Now.ToUnixTimeMilliseconds());
|
||||||
|
|
||||||
|
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||||
|
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||||
|
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||||
|
|
||||||
|
await WaitWhilePausedAsync(token);
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
|
||||||
|
|
||||||
|
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
||||||
|
State = DownloadState.Downloading,
|
||||||
|
Percent = dataLog.Percent,
|
||||||
|
Time = dataLog.Time,
|
||||||
|
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||||
|
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
|
await WaitWhilePausedAsync(token);
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
}
|
||||||
|
} catch (Exception ex){
|
||||||
|
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
|
||||||
|
markError();
|
||||||
|
cancellationSource.Cancel();
|
||||||
|
} finally{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(bool Ok, PartsData Parts)> DownloadSegmentsBufferedResumeAsync(List<dynamic> segments, string fn){
|
public async Task<(bool Ok, PartsData Parts)> DownloadSegmentsBufferedResumeAsync(List<dynamic> segments, string fn){
|
||||||
var totalSeg = _data.Parts.Total;
|
var totalSeg = _data.Parts.Total;
|
||||||
string sessionId = Path.GetFileNameWithoutExtension(fn);
|
string sessionId = Path.GetFileNameWithoutExtension(fn);
|
||||||
string tempDir = Path.Combine(Path.GetDirectoryName(fn), $"{sessionId}_temp");
|
string tempDir = Path.Combine(Path.GetDirectoryName(fn) ?? string.Empty, $"{sessionId}_temp");
|
||||||
|
|
||||||
Directory.CreateDirectory(tempDir);
|
Directory.CreateDirectory(tempDir);
|
||||||
|
|
||||||
|
|
@ -328,6 +445,7 @@ public class HlsDownloader{
|
||||||
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
|
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
|
||||||
mergedParts = (int?)resumeData?.MergedParts ?? 0;
|
mergedParts = (int?)resumeData?.MergedParts ?? 0;
|
||||||
} catch{
|
} catch{
|
||||||
|
// ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,13 +454,18 @@ public class HlsDownloader{
|
||||||
|
|
||||||
var semaphore = new SemaphoreSlim(_data.Threads);
|
var semaphore = new SemaphoreSlim(_data.Threads);
|
||||||
var downloadTasks = new List<Task>();
|
var downloadTasks = new List<Task>();
|
||||||
bool errorOccurred = false;
|
int errorOccurred = 0;
|
||||||
|
|
||||||
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
var cts = new CancellationTokenSource();
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
|
||||||
var token = cts.Token;
|
var token = cts.Token;
|
||||||
|
|
||||||
|
void CleanupBufferedArtifacts(bool cleanAll = true){
|
||||||
|
CleanupNewDownloadMethod(tempDir, resumeFile, cleanAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
try{
|
||||||
for (int i = 0; i < segments.Count; i++){
|
for (int i = 0; i < segments.Count; i++){
|
||||||
try{
|
try{
|
||||||
await semaphore.WaitAsync(token);
|
await semaphore.WaitAsync(token);
|
||||||
|
|
@ -358,111 +481,49 @@ public class HlsDownloader{
|
||||||
int index = i;
|
int index = i;
|
||||||
|
|
||||||
|
|
||||||
downloadTasks.Add(Task.Run(async () => {
|
downloadTasks.Add(DownloadBufferedSegmentAsync(index, segments, tempDir, resumeFile, totalSeg, mergedParts, semaphore, cts, token,
|
||||||
try{
|
() => Interlocked.Exchange(ref errorOccurred, 1),
|
||||||
token.ThrowIfCancellationRequested();
|
() => Volatile.Read(ref _lastUiUpdate),
|
||||||
var segment = new Segment{
|
value => Interlocked.Exchange(ref _lastUiUpdate, value)));
|
||||||
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
|
|
||||||
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
|
|
||||||
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
|
|
||||||
};
|
|
||||||
|
|
||||||
var data = await DownloadPart(segment, index, _data.Offset);
|
|
||||||
|
|
||||||
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
|
|
||||||
await File.WriteAllBytesAsync(tempFile, data);
|
|
||||||
|
|
||||||
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
|
|
||||||
lock (_resumeLock){
|
|
||||||
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
|
|
||||||
DownloadedParts = currentDownloaded,
|
|
||||||
MergedParts = mergedParts,
|
|
||||||
Total = totalSeg
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - _lastUiUpdate > 500){
|
|
||||||
var dataLog = GetDownloadInfo(
|
|
||||||
_lastUiUpdate,
|
|
||||||
currentDownloaded,
|
|
||||||
totalSeg,
|
|
||||||
_data.BytesDownloaded,
|
|
||||||
_data.TotalBytes
|
|
||||||
);
|
|
||||||
|
|
||||||
_data.BytesDownloaded = 0;
|
|
||||||
_lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
|
||||||
|
|
||||||
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
|
||||||
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
|
||||||
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
|
||||||
|
|
||||||
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
|
|
||||||
|
|
||||||
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
|
||||||
IsDownloading = true,
|
|
||||||
Percent = dataLog.Percent,
|
|
||||||
Time = dataLog.Time,
|
|
||||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
|
||||||
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
|
||||||
cts.Cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
|
||||||
|
|
||||||
while (_currentEpMeta.Paused){
|
|
||||||
await Task.Delay(500);
|
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
|
||||||
cts.Cancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception ex){
|
|
||||||
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
|
|
||||||
errorOccurred = true;
|
|
||||||
cts.Cancel();
|
|
||||||
} finally{
|
|
||||||
semaphore.Release();
|
|
||||||
}
|
|
||||||
}, token));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try{
|
try{
|
||||||
await Task.WhenAll(downloadTasks);
|
await Task.WhenAll(downloadTasks);
|
||||||
} catch (OperationCanceledException){
|
} catch (OperationCanceledException){
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
if (!_currentEpMeta.DownloadProgress.Done){
|
CleanupBufferedArtifacts();
|
||||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
|
||||||
}
|
|
||||||
} else{
|
} else{
|
||||||
Console.Error.WriteLine("Download cancelled due to error.");
|
Console.Error.WriteLine("Download cancelled due to error.");
|
||||||
|
CleanupBufferedArtifacts(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (false, _data.Parts);
|
return (false, _data.Parts);
|
||||||
}
|
}
|
||||||
|
} finally{
|
||||||
|
cts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
if (errorOccurred)
|
if (Volatile.Read(ref errorOccurred) == 1){
|
||||||
|
CleanupBufferedArtifacts(false);
|
||||||
return (false, _data.Parts);
|
return (false, _data.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
||||||
for (int i = mergedParts; i < segments.Count; i++){
|
for (int i = mergedParts; i < segments.Count; i++){
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested){
|
||||||
|
CleanupBufferedArtifacts();
|
||||||
return (false, _data.Parts);
|
return (false, _data.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp");
|
string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp");
|
||||||
if (!File.Exists(tempFile)){
|
if (!File.Exists(tempFile)){
|
||||||
Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge.");
|
Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge.");
|
||||||
|
CleanupBufferedArtifacts(false);
|
||||||
return (false, _data.Parts);
|
return (false, _data.Parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] data = await File.ReadAllBytesAsync(tempFile);
|
byte[] data = await File.ReadAllBytesAsync(tempFile, token);
|
||||||
await output.WriteAsync(data, 0, data.Length);
|
await output.WriteAsync(data, 0, data.Length, token);
|
||||||
|
|
||||||
mergedParts++;
|
mergedParts++;
|
||||||
|
|
||||||
|
|
@ -475,21 +536,24 @@ public class HlsDownloader{
|
||||||
var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes);
|
var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes);
|
||||||
Console.WriteLine($"{mergedParts}/{totalSeg} parts merged [{dataLog.Percent}%]");
|
Console.WriteLine($"{mergedParts}/{totalSeg} parts merged [{dataLog.Percent}%]");
|
||||||
|
|
||||||
|
await WaitWhilePausedAsync(token);
|
||||||
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
|
CleanupBufferedArtifacts();
|
||||||
|
return (false, _data.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
||||||
IsDownloading = true,
|
State = DownloadState.Processing,
|
||||||
Percent = dataLog.Percent,
|
Percent = dataLog.Percent,
|
||||||
Time = dataLog.Time,
|
Time = dataLog.Time,
|
||||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||||
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
|
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
|
||||||
};
|
};
|
||||||
|
|
||||||
QueueManager.Instance.Queue.Refresh();
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
|
||||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||||
if (!_currentEpMeta.DownloadProgress.Done){
|
CleanupBufferedArtifacts();
|
||||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (false, _data.Parts);
|
return (false, _data.Parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,14 +567,7 @@ public class HlsDownloader{
|
||||||
|
|
||||||
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
|
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
|
||||||
if (cleanAll){
|
if (cleanAll){
|
||||||
// Delete downloaded files
|
CleanupDownloadedFiles();
|
||||||
foreach (var file in _currentEpMeta.downloadedFiles){
|
|
||||||
try{
|
|
||||||
File.Delete(file); // Safe: File.Delete does nothing if file doesn't exist
|
|
||||||
} catch (Exception ex){
|
|
||||||
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete temp directory
|
// Delete temp directory
|
||||||
|
|
@ -565,6 +622,7 @@ public class HlsDownloader{
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
|
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
|
||||||
|
_cancellationToken.ThrowIfCancellationRequested();
|
||||||
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
|
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
|
||||||
byte[]? dec = null;
|
byte[]? dec = null;
|
||||||
int p = segIndex;
|
int p = segIndex;
|
||||||
|
|
@ -572,7 +630,7 @@ public class HlsDownloader{
|
||||||
byte[]? part;
|
byte[]? part;
|
||||||
if (seg.Key != null){
|
if (seg.Key != null){
|
||||||
var decipher = await GetKey(seg.Key, p, segOffset);
|
var decipher = await GetKey(seg.Key, p, segOffset);
|
||||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
|
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
|
||||||
var partContent = part;
|
var partContent = part;
|
||||||
using (decipher){
|
using (decipher){
|
||||||
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
|
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
|
||||||
|
|
@ -583,7 +641,7 @@ public class HlsDownloader{
|
||||||
Interlocked.Add(ref _data.TotalBytes, dec.Length);
|
Interlocked.Add(ref _data.TotalBytes, dec.Length);
|
||||||
}
|
}
|
||||||
} else{
|
} else{
|
||||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
|
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
|
||||||
dec = part;
|
dec = part;
|
||||||
if (dec != null){
|
if (dec != null){
|
||||||
Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
|
Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
|
||||||
|
|
@ -646,7 +704,7 @@ public class HlsDownloader{
|
||||||
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
|
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
|
||||||
if (!_data.Keys.ContainsKey(kUri)){
|
if (!_data.Keys.ContainsKey(kUri)){
|
||||||
try{
|
try{
|
||||||
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries);
|
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries, _cancellationToken);
|
||||||
if (rkey == null || rkey.Length != 16){
|
if (rkey == null || rkey.Length != 16){
|
||||||
throw new Exception("Key not fully downloaded or is incorrect.");
|
throw new Exception("Key not fully downloaded or is incorrect.");
|
||||||
}
|
}
|
||||||
|
|
@ -662,7 +720,8 @@ public class HlsDownloader{
|
||||||
return _data.Keys[kUri];
|
return _data.Keys[kUri];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount){
|
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount, CancellationToken cancellationToken){
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
// Handle local file URI
|
// Handle local file URI
|
||||||
if (uri.StartsWith("file://")){
|
if (uri.StartsWith("file://")){
|
||||||
string path = new Uri(uri).LocalPath;
|
string path = new Uri(uri).LocalPath;
|
||||||
|
|
@ -680,17 +739,18 @@ public class HlsDownloader{
|
||||||
request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent);
|
request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount);
|
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){
|
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount, CancellationToken cancellationToken){
|
||||||
HttpResponseMessage response;
|
HttpResponseMessage response;
|
||||||
for (int attempt = 0; attempt < retryCount + 1; attempt++){
|
for (int attempt = 0; attempt < retryCount + 1; attempt++){
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
using (var request = CloneHttpRequestMessage(requestPara)){
|
using (var request = CloneHttpRequestMessage(requestPara)){
|
||||||
try{
|
try{
|
||||||
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
return await ReadContentAsByteArrayAsync(response.Content);
|
return await ReadContentAsByteArrayAsync(response.Content, cancellationToken);
|
||||||
} catch (Exception ex) when (ex is HttpRequestException or IOException){
|
} catch (Exception ex) when (ex is HttpRequestException or IOException){
|
||||||
// Log retry attempts
|
// Log retry attempts
|
||||||
string partType = isKey ? "Key" : "Part";
|
string partType = isKey ? "Key" : "Part";
|
||||||
|
|
@ -700,7 +760,7 @@ public class HlsDownloader{
|
||||||
if (attempt == retryCount)
|
if (attempt == retryCount)
|
||||||
throw; // rethrow after last retry
|
throw; // rethrow after last retry
|
||||||
|
|
||||||
await Task.Delay(_data.WaitTime);
|
await Task.Delay(_data.WaitTime, cancellationToken);
|
||||||
} catch (Exception ex){
|
} catch (Exception ex){
|
||||||
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
|
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
|
||||||
Console.Error.WriteLine($"\tType: {ex.GetType()}");
|
Console.Error.WriteLine($"\tType: {ex.GetType()}");
|
||||||
|
|
@ -713,14 +773,14 @@ public class HlsDownloader{
|
||||||
return null; // Should not reach here
|
return null; // Should not reach here
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content){
|
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content, CancellationToken cancellationToken){
|
||||||
using (var memoryStream = new MemoryStream())
|
using (var memoryStream = new MemoryStream())
|
||||||
using (var contentStream = await content.ReadAsStreamAsync())
|
using (var contentStream = await content.ReadAsStreamAsync(cancellationToken))
|
||||||
using (var throttledStream = new ThrottledStream(contentStream)){
|
using (var throttledStream = new ThrottledStream(contentStream)){
|
||||||
byte[] buffer = new byte[8192];
|
byte[] buffer = new byte[8192];
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
while ((bytesRead = await throttledStream.ReadAsync(buffer, 0, buffer.Length)) > 0){
|
while ((bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0){
|
||||||
await memoryStream.WriteAsync(buffer, 0, bytesRead);
|
await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
|
|
@ -757,7 +817,7 @@ public class HlsDownloader{
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class HttpContentExtensions{
|
public static class HttpContentExtensions{
|
||||||
public static HttpContent Clone(this HttpContent content){
|
public static HttpContent? Clone(this HttpContent? content){
|
||||||
if (content == null) return null;
|
if (content == null) return null;
|
||||||
var memStream = new MemoryStream();
|
var memStream = new MemoryStream();
|
||||||
content.CopyToAsync(memStream).Wait();
|
content.CopyToAsync(memStream).Wait();
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,40 @@ using CRD.Utils.Http;
|
||||||
using CRD.Utils.JsonConv;
|
using CRD.Utils.JsonConv;
|
||||||
using CRD.Utils.Parser;
|
using CRD.Utils.Parser;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using NuGet.Versioning;
|
||||||
|
|
||||||
namespace CRD.Utils;
|
namespace CRD.Utils;
|
||||||
|
|
||||||
public class Helpers{
|
public class Helpers{
|
||||||
|
private static readonly Regex ClientVersionRegex = new(@"(?:ANDROIDTV|Crunchyroll)/(?<version>[0-9]+(?:[._][0-9]+)*)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static string? ExtractClientVersion(string? userAgent){
|
||||||
|
if (string.IsNullOrWhiteSpace(userAgent)){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var match = ClientVersionRegex.Match(userAgent);
|
||||||
|
return match.Success ? match.Groups["version"].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CompareClientVersions(string? left, string? right){
|
||||||
|
var leftVersion = ParseClientVersion(left);
|
||||||
|
var rightVersion = ParseClientVersion(right);
|
||||||
|
|
||||||
|
return VersionComparer.Version.Compare(leftVersion, rightVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NuGetVersion ParseClientVersion(string? version){
|
||||||
|
return NuGetVersion.TryParse(version?.Replace('_', '.') ?? "", out var nuGetVersion)
|
||||||
|
? nuGetVersion
|
||||||
|
: NuGetVersion.Parse("0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
|
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
|
||||||
try{
|
try{
|
||||||
serializerSettings ??= new JsonSerializerSettings();
|
serializerSettings ??= new JsonSerializerSettings();
|
||||||
|
|
@ -62,6 +88,19 @@ public class Helpers{
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int GetRetryDelaySeconds(CrDownloadOptions options, int retryAttemptCount){
|
||||||
|
return GetRetryDelaySeconds(options.PlaybackRateLimitRetryDelaySeconds, options.RetryMaxDelaySeconds, retryAttemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetRetryDelaySeconds(int baseDelaySeconds, int maxDelaySeconds, int retryAttemptCount){
|
||||||
|
int baseDelay = Math.Max(1, baseDelaySeconds);
|
||||||
|
int maxDelay = Math.Max(baseDelay, maxDelaySeconds);
|
||||||
|
|
||||||
|
int attempt = Math.Max(0, retryAttemptCount);
|
||||||
|
double delay = baseDelay * Math.Pow(2, attempt);
|
||||||
|
return (int)Math.Min(maxDelay, delay);
|
||||||
|
}
|
||||||
|
|
||||||
public static T? DeepCopy<T>(T obj){
|
public static T? DeepCopy<T>(T obj){
|
||||||
var settings = new JsonSerializerSettings{
|
var settings = new JsonSerializerSettings{
|
||||||
ContractResolver = new DefaultContractResolver{
|
ContractResolver = new DefaultContractResolver{
|
||||||
|
|
@ -193,7 +232,7 @@ public class Helpers{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command){
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command, CancellationToken cancellationToken = default){
|
||||||
try{
|
try{
|
||||||
using (var process = new Process()){
|
using (var process = new Process()){
|
||||||
process.StartInfo.FileName = bin;
|
process.StartInfo.FileName = bin;
|
||||||
|
|
@ -224,7 +263,17 @@ public class Helpers{
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
await process.WaitForExitAsync();
|
await using var registration = cancellationToken.Register(() => {
|
||||||
|
try{
|
||||||
|
if (!process.HasExited){
|
||||||
|
process.Kill(true);
|
||||||
|
}
|
||||||
|
} catch{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
|
||||||
bool isSuccess = process.ExitCode == 0;
|
bool isSuccess = process.ExitCode == 0;
|
||||||
|
|
||||||
|
|
@ -236,69 +285,162 @@ public class Helpers{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void DeleteFile(string filePath){
|
public static bool DeleteFile(string filePath, int maxRetries = 5, int delayMs = 150){
|
||||||
if (string.IsNullOrEmpty(filePath)){
|
if (string.IsNullOrEmpty(filePath)){
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < maxRetries; attempt++){
|
||||||
try{
|
try{
|
||||||
if (File.Exists(filePath)){
|
if (!File.Exists(filePath)){
|
||||||
File.Delete(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){
|
} catch (Exception ex){
|
||||||
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: file remained locked after {maxRetries} attempts.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetAvailableDestinationPath(string destinationPath){
|
||||||
|
if (!File.Exists(destinationPath)){
|
||||||
|
return destinationPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(destinationPath) ?? string.Empty;
|
||||||
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(destinationPath);
|
||||||
|
var extension = Path.GetExtension(destinationPath);
|
||||||
|
var counter = 1;
|
||||||
|
|
||||||
|
string candidatePath;
|
||||||
|
do{
|
||||||
|
candidatePath = Path.Combine(directory, $"{fileNameWithoutExtension}({counter}){extension}");
|
||||||
|
counter++;
|
||||||
|
} while (File.Exists(candidatePath));
|
||||||
|
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
|
||||||
try{
|
Process? process = null;
|
||||||
using (var process = new Process()){
|
DataReceivedEventHandler? outputHandler = null;
|
||||||
process.StartInfo.WorkingDirectory = workingDir;
|
DataReceivedEventHandler? errorHandler = null;
|
||||||
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.OutputDataReceived += (sender, e) => {
|
try{
|
||||||
|
process = new Process{
|
||||||
|
StartInfo = new ProcessStartInfo{
|
||||||
|
WorkingDirectory = workingDir,
|
||||||
|
FileName = bin,
|
||||||
|
Arguments = command,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
},
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
};
|
||||||
|
|
||||||
|
outputHandler = (_, e) => {
|
||||||
if (!string.IsNullOrEmpty(e.Data)){
|
if (!string.IsNullOrEmpty(e.Data)){
|
||||||
Console.WriteLine(e.Data);
|
Console.WriteLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
process.ErrorDataReceived += (sender, e) => {
|
errorHandler = (_, e) => {
|
||||||
if (!string.IsNullOrEmpty(e.Data)){
|
if (!string.IsNullOrEmpty(e.Data)){
|
||||||
Console.Error.WriteLine($"{e.Data}");
|
Console.Error.WriteLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
process.OutputDataReceived += outputHandler;
|
||||||
|
process.ErrorDataReceived += errorHandler;
|
||||||
|
|
||||||
process.Start();
|
process.Start();
|
||||||
|
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
await process.WaitForExitAsync();
|
await process.WaitForExitAsync();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
bool isSuccess = process.ExitCode == 0;
|
return (IsOk: process.ExitCode == 0, ErrorCode: process.ExitCode);
|
||||||
|
|
||||||
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
|
||||||
}
|
|
||||||
} catch (Exception ex){
|
} catch (Exception ex){
|
||||||
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
||||||
return (IsOk: false, ErrorCode: -1);
|
return (IsOk: false, ErrorCode: -1);
|
||||||
|
} finally{
|
||||||
|
if (process != null){
|
||||||
|
if (outputHandler != null){
|
||||||
|
process.OutputDataReceived -= outputHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorHandler != null){
|
||||||
|
process.ErrorDataReceived -= errorHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
|
public static IEnumerable<string> GetQualityOption(VideoPreset preset){
|
||||||
|
if (preset.Crf is -1)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var q = preset.Crf.ToString();
|
||||||
|
|
||||||
return preset.Codec switch{
|
return preset.Codec switch{
|
||||||
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
|
"h264_nvenc" or "hevc_nvenc"
|
||||||
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
|
=> preset.Crf is >= 0 and <= 51 ? ["-cq", q] : [],
|
||||||
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
|
|
||||||
_ =>["-crf", preset.Crf.ToString()]
|
"h264_qsv" or "hevc_qsv"
|
||||||
|
=> preset.Crf is >= 1 and <= 51 ? ["-global_quality", q] : [],
|
||||||
|
|
||||||
|
"h264_amf"
|
||||||
|
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q, "-qp_b", q] : [],
|
||||||
|
|
||||||
|
"hevc_amf"
|
||||||
|
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q] : [],
|
||||||
|
|
||||||
|
_ // libx264/libx265/etc.
|
||||||
|
=> preset.Crf >= 0 ? ["-crf", q] : []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<string> BuildFFmpegArgsForPreset(string inputFilePath, VideoPreset preset, string outputFilePath){
|
||||||
|
var args = new List<string>{
|
||||||
|
"-nostdin",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel", "error",
|
||||||
|
"-i", inputFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(preset.Codec)){
|
||||||
|
args.Add("-c:v");
|
||||||
|
args.Add(preset.Codec);
|
||||||
|
|
||||||
|
args.AddRange(GetQualityOption(preset));
|
||||||
|
|
||||||
|
args.Add("-vf");
|
||||||
|
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var param in preset.AdditionalParameters){
|
||||||
|
args.AddRange(SplitArguments(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
args.Add(outputFilePath);
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
|
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
|
||||||
string inputFilePath,
|
string inputFilePath,
|
||||||
VideoPreset preset,
|
VideoPreset preset,
|
||||||
|
|
@ -311,29 +453,7 @@ public class Helpers{
|
||||||
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
|
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
|
||||||
|
|
||||||
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
|
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
|
||||||
|
var args = BuildFFmpegArgsForPreset(inputFilePath, preset, tempOutput);
|
||||||
var args = new List<string>{
|
|
||||||
"-nostdin",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel", "error",
|
|
||||||
"-i", inputFilePath,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(preset.Codec)){
|
|
||||||
args.Add("-c:v");
|
|
||||||
args.Add(preset.Codec);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.AddRange(GetQualityOption(preset));
|
|
||||||
|
|
||||||
args.Add("-vf");
|
|
||||||
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
|
|
||||||
|
|
||||||
foreach (var param in preset.AdditionalParameters){
|
|
||||||
args.AddRange(SplitArguments(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
args.Add(tempOutput);
|
|
||||||
|
|
||||||
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
|
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
|
||||||
int exitCode;
|
int exitCode;
|
||||||
|
|
@ -409,7 +529,7 @@ public class Helpers{
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string BuildCommandString(string exe, IEnumerable<string> args){
|
public static string BuildCommandString(string exe, IEnumerable<string> args){
|
||||||
static string Quote(string s){
|
static string Quote(string s){
|
||||||
if (string.IsNullOrWhiteSpace(s))
|
if (string.IsNullOrWhiteSpace(s))
|
||||||
return "\"\"";
|
return "\"\"";
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,10 @@ public class HttpClientReq{
|
||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
|
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
|
||||||
bool allowChallengeBypass = true){
|
bool allowChallengeBypass = true){
|
||||||
string content = string.Empty;
|
string content = string.Empty;
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
try{
|
try{
|
||||||
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
|
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
|
||||||
AttachCookies(request, cookieStore);
|
AttachCookies(request, cookieStore);
|
||||||
|
|
@ -131,7 +132,7 @@ public class HttpClientReq{
|
||||||
retryRequest, GetCookiesForRequest(cookieStore));
|
retryRequest, GetCookiesForRequest(cookieStore));
|
||||||
|
|
||||||
if (!solverResult.IsOk){
|
if (!solverResult.IsOk){
|
||||||
return (false, solverResult.ResponseContent, "Challenge bypass failed");
|
return (false, solverResult.ResponseContent, "Challenge bypass failed",headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// foreach (var cookie in solverResult.Cookies){
|
// foreach (var cookie in solverResult.Cookies){
|
||||||
|
|
@ -139,30 +140,36 @@ public class HttpClientReq{
|
||||||
// AddCookie(cookie.Domain, cookie, cookieStore);
|
// AddCookie(cookie.Domain, cookie, cookieStore);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "");
|
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "",headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (false, content, "Cloudflare challenge detected");
|
return (false, content, "Cloudflare challenge detected",headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
content = await response.Content.ReadAsStringAsync();
|
content = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
foreach (var header in response.Headers)
|
||||||
|
headers[header.Key] = string.Join(", ", header.Value);
|
||||||
|
|
||||||
|
foreach (var header in response.Content.Headers)
|
||||||
|
headers[header.Key] = string.Join(", ", header.Value);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
|
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
|
||||||
|
|
||||||
return (IsOk: true, ResponseContent: content, error: "");
|
return (IsOk: true, ResponseContent: content, error: "",headers);
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
if (!suppressError){
|
if (!suppressError){
|
||||||
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
||||||
}
|
}
|
||||||
return (IsOk: false, ResponseContent: content, error: "");
|
return (IsOk: false, ResponseContent: content, error: "",headers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
|
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
|
||||||
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured");
|
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured",[]);
|
||||||
string content = string.Empty;
|
string content = string.Empty;
|
||||||
try{
|
try{
|
||||||
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
|
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
|
||||||
|
|
@ -170,13 +177,13 @@ public class HttpClientReq{
|
||||||
|
|
||||||
content = flareSolverrResponses.ResponseContent;
|
content = flareSolverrResponses.ResponseContent;
|
||||||
|
|
||||||
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "");
|
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "",[]);
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
if (!suppressError){
|
if (!suppressError){
|
||||||
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (IsOk: false, ResponseContent: content, error: "");
|
return (IsOk: false, ResponseContent: content, error: "",[]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
using CRD.Utils.Http;
|
|
||||||
using CRD.Utils.Muxing.Structs;
|
using CRD.Utils.Muxing.Structs;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Views;
|
using CRD.Views;
|
||||||
|
|
@ -108,55 +108,27 @@ public class FontsManager{
|
||||||
{ "Webdings", "webdings.ttf" }
|
{ "Webdings", "webdings.ttf" }
|
||||||
};
|
};
|
||||||
|
|
||||||
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
|
|
||||||
|
|
||||||
|
|
||||||
private readonly FontIndex index = new();
|
private readonly FontIndex index = new();
|
||||||
|
private int _fontSourceNoticePrinted;
|
||||||
|
|
||||||
private void EnsureIndex(string fontsDir){
|
private void EnsureIndex(string fontsDir){
|
||||||
index.Rebuild(fontsDir);
|
index.Rebuild(GetFontSearchDirectories(fontsDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task GetFontsAsync(){
|
public Task GetFontsAsync(){
|
||||||
Console.WriteLine("Downloading fonts...");
|
|
||||||
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{
|
try{
|
||||||
if (!Directory.Exists(fontFolder))
|
Directory.CreateDirectory(CfgManager.PathFONTS_DIR);
|
||||||
Directory.CreateDirectory(fontFolder!);
|
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
Console.WriteLine($"Failed to create directory: {e.Message}");
|
Console.Error.WriteLine($"Failed to create fonts directory '{CfgManager.PathFONTS_DIR}': {e.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var fontUrl = root + font;
|
if (Interlocked.Exchange(ref _fontSourceNoticePrinted, 1) == 0){
|
||||||
|
Console.WriteLine("Crunchyroll-hosted subtitle fonts are no longer available.");
|
||||||
var httpClient = HttpClientReq.Instance.GetHttpClient();
|
Console.WriteLine($"Font muxing now uses local fonts from '{CfgManager.PathFONTS_DIR}' and system font directories.");
|
||||||
try{
|
Console.WriteLine("Copy any missing subtitle fonts into the local fonts folder if muxing reports them as missing.");
|
||||||
var response = await httpClient.GetAsync(fontUrl);
|
|
||||||
if (response.IsSuccessStatusCode){
|
|
||||||
var fontData = await response.Content.ReadAsByteArrayAsync();
|
|
||||||
await File.WriteAllBytesAsync(fontLoc, fontData);
|
|
||||||
Console.WriteLine($"Downloaded: {font}");
|
|
||||||
} else{
|
|
||||||
Console.Error.WriteLine($"Failed to download: {font}");
|
|
||||||
}
|
|
||||||
} catch (Exception e){
|
|
||||||
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("All required fonts downloaded!");
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<string> ExtractFontsFromAss(string ass, bool checkTypesettingFonts){
|
public static List<string> ExtractFontsFromAss(string ass, bool checkTypesettingFonts){
|
||||||
|
|
@ -273,7 +245,7 @@ public class FontsManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.Count > 0)
|
if (missing.Count > 0)
|
||||||
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}");
|
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}\n\nAdd the missing font files to:\n{CfgManager.PathFONTS_DIR}");
|
||||||
|
|
||||||
return fontsList;
|
return fontsList;
|
||||||
}
|
}
|
||||||
|
|
@ -288,8 +260,8 @@ public class FontsManager{
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (Fonts.TryGetValue(req, out var crFile)){
|
if (Fonts.TryGetValue(req, out var crFile)){
|
||||||
var p = Path.Combine(fontsDir, crFile);
|
var p = FindKnownFontFile(crFile, fontsDir);
|
||||||
if (File.Exists(p)){
|
if (!string.IsNullOrEmpty(p)){
|
||||||
resolvedPath = p;
|
resolvedPath = p;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -303,27 +275,44 @@ public class FontsManager{
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (Fonts.TryGetValue(family, out var crFamilyFile)){
|
if (Fonts.TryGetValue(family, out var crFamilyFile)){
|
||||||
var p = Path.Combine(fontsDir, crFamilyFile);
|
var p = FindKnownFontFile(crFamilyFile, fontsDir);
|
||||||
if (File.Exists(p)){
|
if (!string.IsNullOrEmpty(p)){
|
||||||
resolvedPath = p;
|
resolvedPath = p;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reqNoSpace = RemoveSpaces(req);
|
||||||
|
|
||||||
|
foreach (var kv in Fonts){
|
||||||
|
if (RemoveSpaces(kv.Key).Equals(reqNoSpace, StringComparison.OrdinalIgnoreCase)){
|
||||||
|
var p = FindKnownFontFile(kv.Value, fontsDir);
|
||||||
|
if (!string.IsNullOrEmpty(p)){
|
||||||
|
resolvedPath = p;
|
||||||
|
isExactMatch = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string StripStyleSuffix(string name){
|
private static string StripStyleSuffix(string name){
|
||||||
var n = name;
|
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$",
|
var styleWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase){
|
||||||
"", RegexOptions.IgnoreCase).Trim();
|
"Bold", "Italic", "Oblique", "Regular", "Black",
|
||||||
|
"Light", "Medium", "Semi", "Condensed"
|
||||||
|
};
|
||||||
|
|
||||||
return n;
|
var filtered = parts.Where(p => !styleWords.Contains(p)).ToList();
|
||||||
|
|
||||||
|
return filtered.Count > 0 ? string.Join(" ", filtered) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string NormalizeFontKey(string s){
|
private static string NormalizeFontKey(string s){
|
||||||
if (string.IsNullOrWhiteSpace(s))
|
if (string.IsNullOrWhiteSpace(s))
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
|
|
@ -332,17 +321,24 @@ public class FontsManager{
|
||||||
if (s.StartsWith("@"))
|
if (s.StartsWith("@"))
|
||||||
s = s.Substring(1);
|
s = s.Substring(1);
|
||||||
|
|
||||||
|
// Convert camel case (TimesNewRoman → Times New Roman)
|
||||||
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
|
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
|
||||||
|
|
||||||
|
// unify separators
|
||||||
s = s.Replace('_', ' ').Replace('-', ' ');
|
s = s.Replace('_', ' ').Replace('-', ' ');
|
||||||
|
|
||||||
s = Regex.Replace(s, @"\s+", " ").Trim();
|
// remove MT suffix (ArialMT → Arial)
|
||||||
|
s = Regex.Replace(s, @"MT$", "", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase);
|
// collapse spaces
|
||||||
|
s = Regex.Replace(s, @"\s+", " ").Trim();
|
||||||
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string RemoveSpaces(string s)
|
||||||
|
=> s.Replace(" ", "");
|
||||||
|
|
||||||
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
|
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
|
||||||
var baseName = Path.GetFileName(path);
|
var baseName = Path.GetFileName(path);
|
||||||
|
|
||||||
|
|
@ -356,24 +352,80 @@ public class FontsManager{
|
||||||
return $"{hash}-{baseName}";
|
return $"{hash}-{baseName}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetFontSearchDirectories(string fontsDir){
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var paths = new List<string>();
|
||||||
|
|
||||||
|
void AddIfUsable(string? dir){
|
||||||
|
if (string.IsNullOrWhiteSpace(dir))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try{
|
||||||
|
var fullPath = Path.GetFullPath(dir);
|
||||||
|
if (Directory.Exists(fullPath) && seen.Add(fullPath))
|
||||||
|
paths.Add(fullPath);
|
||||||
|
} catch{
|
||||||
|
// ignore invalid paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddIfUsable(fontsDir);
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
|
||||||
|
AddIfUsable(Environment.GetFolderPath(Environment.SpecialFolder.Fonts));
|
||||||
|
} else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)){
|
||||||
|
AddIfUsable("/System/Library/Fonts");
|
||||||
|
AddIfUsable("/Library/Fonts");
|
||||||
|
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Fonts"));
|
||||||
|
} else{
|
||||||
|
AddIfUsable("/usr/share/fonts");
|
||||||
|
AddIfUsable("/usr/local/share/fonts");
|
||||||
|
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"));
|
||||||
|
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "fonts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindKnownFontFile(string fileName, string fontsDir){
|
||||||
|
foreach (var dir in GetFontSearchDirectories(fontsDir)){
|
||||||
|
var path = Path.Combine(dir, fileName);
|
||||||
|
if (File.Exists(path))
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private sealed class FontIndex{
|
private sealed class FontIndex{
|
||||||
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public void Rebuild(string fontsDir){
|
public void Rebuild(IEnumerable<string> fontDirs){
|
||||||
map.Clear();
|
map.Clear();
|
||||||
if (!Directory.Exists(fontsDir)) return;
|
foreach (var fontsDir in fontDirs){
|
||||||
|
if (!Directory.Exists(fontsDir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try{
|
||||||
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
|
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
|
||||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
|
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
try{
|
||||||
foreach (var desc in LoadDescriptions(path)){
|
foreach (var desc in LoadDescriptions(path)){
|
||||||
foreach (var alias in BuildAliases(desc)){
|
foreach (var alias in BuildAliases(desc)){
|
||||||
Add(alias, path);
|
Add(alias, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (Exception e){
|
||||||
|
Console.Error.WriteLine($"Failed to inspect font '{path}': {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e){
|
||||||
|
Console.Error.WriteLine($"Failed to scan font directory '{fontsDir}': {e.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,9 +492,9 @@ public class FontsManager{
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> BuildAliases(FontDescription d){
|
private static IEnumerable<string> BuildAliases(FontDescription d){
|
||||||
var family = d.FontFamilyInvariantCulture.Trim();
|
var family = d.FontFamilyInvariantCulture?.Trim() ?? string.Empty;
|
||||||
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
|
var sub = d.FontSubFamilyNameInvariantCulture?.Trim() ?? string.Empty; // Regular/Bold/Italic
|
||||||
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily"
|
var full = d.FontNameInvariantCulture?.Trim() ?? string.Empty; // "Family Subfamily"
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(family)) yield return family;
|
if (!string.IsNullOrWhiteSpace(family)) yield return family;
|
||||||
if (!string.IsNullOrWhiteSpace(full)) yield return full;
|
if (!string.IsNullOrWhiteSpace(full)) yield return full;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CRD.Utils.Muxing.Commands;
|
using CRD.Utils.Muxing.Commands;
|
||||||
using CRD.Utils.Muxing.Structs;
|
using CRD.Utils.Muxing.Structs;
|
||||||
|
|
@ -27,7 +28,7 @@ public class Merger{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<bool> Merge(string type, string bin){
|
public async Task<bool> Merge(string type, string bin, CancellationToken cancellationToken = default){
|
||||||
string command = type switch{
|
string command = type switch{
|
||||||
"ffmpeg" => FFmpeg(),
|
"ffmpeg" => FFmpeg(),
|
||||||
"mkvmerge" => MkvMerge(),
|
"mkvmerge" => MkvMerge(),
|
||||||
|
|
@ -40,7 +41,7 @@ public class Merger{
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"[{type}] Started merging");
|
Console.WriteLine($"[{type}] Started merging");
|
||||||
var result = await Helpers.ExecuteCommandAsync(bin, command);
|
var result = await Helpers.ExecuteCommandAsync(bin, command, cancellationToken);
|
||||||
|
|
||||||
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
|
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
|
||||||
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
|
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ public class SyncingHelper{
|
||||||
Pixels = GetPixelsArray(f.FilePath)
|
Pixels = GetPixelsArray(f.FilePath)
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var delay = 0.0;
|
var delay = double.NaN;
|
||||||
|
|
||||||
foreach (var baseFrame in baseFrames){
|
foreach (var baseFrame in baseFrames){
|
||||||
var baseFramePixels = GetPixelsArray(baseFrame.FilePath);
|
var baseFramePixels = GetPixelsArray(baseFrame.FilePath);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ public class VideoSyncer{
|
||||||
public static async Task<(double offSet, double startOffset, double endOffset, double lengthDiff)> ProcessVideo(string baseVideoPath, string compareVideoPath){
|
public static async Task<(double offSet, double startOffset, double endOffset, double lengthDiff)> ProcessVideo(string baseVideoPath, string compareVideoPath){
|
||||||
string baseFramesDir, baseFramesDirEnd;
|
string baseFramesDir, baseFramesDirEnd;
|
||||||
string compareFramesDir, compareFramesDirEnd;
|
string compareFramesDir, compareFramesDirEnd;
|
||||||
string cleanupDir;
|
string cleanupDir = string.Empty;
|
||||||
|
double baseEndWindowOffset = 0;
|
||||||
|
double compareEndWindowOffset = 0;
|
||||||
try{
|
try{
|
||||||
var tempDir = CfgManager.PathTEMP_DIR;
|
var tempDir = CfgManager.PathTEMP_DIR;
|
||||||
string uuid = Guid.NewGuid().ToString();
|
string uuid = Guid.NewGuid().ToString();
|
||||||
|
|
@ -46,8 +48,13 @@ public class VideoSyncer{
|
||||||
return (-100, 0, 0, 0);
|
return (-100, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
var baseEndWindowDuration = Math.Min(360, baseVideoDurationTimeSpan.Value.TotalSeconds);
|
||||||
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
var compareEndWindowDuration = Math.Min(360, compareVideoDurationTimeSpan.Value.TotalSeconds);
|
||||||
|
baseEndWindowOffset = Math.Max(0, baseVideoDurationTimeSpan.Value.TotalSeconds - baseEndWindowDuration);
|
||||||
|
compareEndWindowOffset = Math.Max(0, compareVideoDurationTimeSpan.Value.TotalSeconds - compareEndWindowDuration);
|
||||||
|
|
||||||
|
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseEndWindowOffset, baseEndWindowDuration);
|
||||||
|
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareEndWindowOffset, compareEndWindowDuration);
|
||||||
|
|
||||||
if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){
|
if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){
|
||||||
Console.Error.WriteLine("Failed to extract Frames to Compare");
|
Console.Error.WriteLine("Failed to extract Frames to Compare");
|
||||||
|
|
@ -57,24 +64,24 @@ public class VideoSyncer{
|
||||||
// Load frames from start of the videos
|
// Load frames from start of the videos
|
||||||
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
|
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
|
||||||
FilePath = fp,
|
FilePath = fp,
|
||||||
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate)
|
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate, 0)
|
||||||
}).ToList();
|
}).OrderBy(frame => frame.Time).ToList();
|
||||||
|
|
||||||
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
|
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
|
||||||
FilePath = fp,
|
FilePath = fp,
|
||||||
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate)
|
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate, 0)
|
||||||
}).ToList();
|
}).OrderBy(frame => frame.Time).ToList();
|
||||||
|
|
||||||
// Load frames from end of the videos
|
// Load frames from end of the videos
|
||||||
var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{
|
var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{
|
||||||
FilePath = fp,
|
FilePath = fp,
|
||||||
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate)
|
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate, baseEndWindowOffset)
|
||||||
}).ToList();
|
}).OrderBy(frame => frame.Time).ToList();
|
||||||
|
|
||||||
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
|
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
|
||||||
FilePath = fp,
|
FilePath = fp,
|
||||||
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate)
|
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate, compareEndWindowOffset)
|
||||||
}).ToList();
|
}).OrderBy(frame => frame.Time).ToList();
|
||||||
|
|
||||||
|
|
||||||
// Calculate offsets
|
// Calculate offsets
|
||||||
|
|
@ -83,13 +90,14 @@ public class VideoSyncer{
|
||||||
|
|
||||||
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
|
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
|
||||||
|
|
||||||
endOffset += lengthDiff;
|
if (double.IsNaN(startOffset) || double.IsNaN(endOffset)){
|
||||||
|
Console.Error.WriteLine("Couldn't find enough matching frames to sync dub.");
|
||||||
|
return (-100, startOffset, endOffset, lengthDiff);
|
||||||
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Start offset: {startOffset} seconds");
|
Console.WriteLine($"Start offset: {startOffset} seconds");
|
||||||
Console.WriteLine($"End offset: {endOffset} seconds");
|
Console.WriteLine($"End offset: {endOffset} seconds");
|
||||||
|
|
||||||
CleanupDirectory(cleanupDir);
|
|
||||||
|
|
||||||
baseFramesStart.Clear();
|
baseFramesStart.Clear();
|
||||||
baseFramesEnd.Clear();
|
baseFramesEnd.Clear();
|
||||||
compareFramesStart.Clear();
|
compareFramesStart.Clear();
|
||||||
|
|
@ -112,21 +120,23 @@ public class VideoSyncer{
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
Console.Error.WriteLine(e);
|
Console.Error.WriteLine(e);
|
||||||
return (-100, 0, 0, 0);
|
return (-100, 0, 0, 0);
|
||||||
|
} finally{
|
||||||
|
CleanupDirectory(cleanupDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CleanupDirectory(string dirPath){
|
private static void CleanupDirectory(string dirPath){
|
||||||
if (Directory.Exists(dirPath)){
|
if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)){
|
||||||
Directory.Delete(dirPath, true);
|
Directory.Delete(dirPath, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double GetTimeFromFileName(string fileName, double frameRate){
|
private static double GetTimeFromFileName(string fileName, double frameRate, double timeOffset){
|
||||||
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
|
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
|
||||||
if (match.Success){
|
if (match.Success){
|
||||||
return int.Parse(match.Groups[1].Value) / frameRate;
|
return timeOffset + int.Parse(match.Groups[1].Value) / frameRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return timeOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
10
CRD/Utils/Notifications/INotificationProvider.cs
Normal file
10
CRD/Utils/Notifications/INotificationProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
51
CRD/Utils/Notifications/NotificationDispatcher.cs
Normal file
51
CRD/Utils/Notifications/NotificationDispatcher.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
CRD/Utils/Notifications/NotificationEvent.cs
Normal file
16
CRD/Utils/Notifications/NotificationEvent.cs
Normal 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; } = [];
|
||||||
|
}
|
||||||
10
CRD/Utils/Notifications/NotificationEventType.cs
Normal file
10
CRD/Utils/Notifications/NotificationEventType.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace CRD.Utils.Notifications;
|
||||||
|
|
||||||
|
public enum NotificationEventType{
|
||||||
|
QueueFinished,
|
||||||
|
DownloadFinished,
|
||||||
|
DownloadFailed,
|
||||||
|
TrackedSeriesEpisodeReleased,
|
||||||
|
LoginExpired,
|
||||||
|
UpdateAvailable
|
||||||
|
}
|
||||||
37
CRD/Utils/Notifications/NotificationProviderConfig.cs
Normal file
37
CRD/Utils/Notifications/NotificationProviderConfig.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
CRD/Utils/Notifications/NotificationProviderType.cs
Normal file
7
CRD/Utils/Notifications/NotificationProviderType.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace CRD.Utils.Notifications;
|
||||||
|
|
||||||
|
public enum NotificationProviderType{
|
||||||
|
Sound,
|
||||||
|
Execute,
|
||||||
|
Webhook
|
||||||
|
}
|
||||||
178
CRD/Utils/Notifications/NotificationPublisher.cs
Normal file
178
CRD/Utils/Notifications/NotificationPublisher.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
CRD/Utils/Notifications/NotificationSettings.cs
Normal file
25
CRD/Utils/Notifications/NotificationSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,7 @@ public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposabl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int running = 0;
|
private int running;
|
||||||
|
|
||||||
private async Task SafeRunWork(CancellationToken token){
|
private async Task SafeRunWork(CancellationToken token){
|
||||||
if (Interlocked.Exchange(ref running, 1) == 1){
|
if (Interlocked.Exchange(ref running, 1) == 1){
|
||||||
|
|
|
||||||
48
CRD/Utils/QueueManagement/DownloadItemModelCollection.cs
Normal file
48
CRD/Utils/QueueManagement/DownloadItemModelCollection.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
CRD/Utils/QueueManagement/ProcessingSlotManager.cs
Normal file
78
CRD/Utils/QueueManagement/ProcessingSlotManager.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
CRD/Utils/QueueManagement/QueuePersistenceManager.cs
Normal file
124
CRD/Utils/QueueManagement/QueuePersistenceManager.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
CRD/Utils/QueueManagement/UiMutationQueue.cs
Normal file
79
CRD/Utils/QueueManagement/UiMutationQueue.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
|
||||||
if (match.Success){
|
if (match.Success){
|
||||||
var locale = match.Groups[1].Value; // Capture the locale part
|
var locale = match.Groups[1].Value; // Capture the locale part
|
||||||
var id = match.Groups[2].Value; // Capture the ID part
|
var id = match.Groups[2].Value; // Capture the ID part
|
||||||
await QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using CRD.Utils.Http;
|
using CRD.Utils.Http;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using CRD.Utils.Sonarr;
|
using CRD.Utils.Sonarr;
|
||||||
using CRD.ViewModels;
|
using CRD.ViewModels;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace CRD.Utils.Structs.Crunchyroll;
|
namespace CRD.Utils.Structs.Crunchyroll;
|
||||||
|
|
||||||
|
|
@ -21,6 +23,9 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("remove_finished_downloads")]
|
[JsonProperty("remove_finished_downloads")]
|
||||||
public bool RemoveFinishedDownload{ get; set; }
|
public bool RemoveFinishedDownload{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("persist_queue")]
|
||||||
|
public bool PersistQueue{ get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public int Timeout{ get; set; }
|
public int Timeout{ get; set; }
|
||||||
|
|
||||||
|
|
@ -30,6 +35,12 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("retry_attempts")]
|
[JsonProperty("retry_attempts")]
|
||||||
public int RetryAttempts{ get; set; }
|
public int RetryAttempts{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("playback_rate_limit_retry_delay_seconds")]
|
||||||
|
public int PlaybackRateLimitRetryDelaySeconds{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("retry_max_delay_seconds")]
|
||||||
|
public int RetryMaxDelaySeconds{ get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string Force{ get; set; } = "";
|
public string Force{ get; set; } = "";
|
||||||
|
|
||||||
|
|
@ -66,6 +77,9 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("download_finished_execute_path")]
|
[JsonProperty("download_finished_execute_path")]
|
||||||
public string? DownloadFinishedExecutePath{ get; set; }
|
public string? DownloadFinishedExecutePath{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("notifications")]
|
||||||
|
public NotificationSettings? NotificationSettings{ get; set; }
|
||||||
|
|
||||||
[JsonProperty("download_only_with_all_selected_dubsub")]
|
[JsonProperty("download_only_with_all_selected_dubsub")]
|
||||||
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
|
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
|
||||||
|
|
||||||
|
|
@ -93,6 +107,9 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("history_include_cr_artists")]
|
[JsonProperty("history_include_cr_artists")]
|
||||||
public bool HistoryIncludeCrArtists{ get; set; }
|
public bool HistoryIncludeCrArtists{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("history_remove_missing_episodes")]
|
||||||
|
public bool HistoryRemoveMissingEpisodes{ get; set; } = true;
|
||||||
|
|
||||||
[JsonProperty("history_lang")]
|
[JsonProperty("history_lang")]
|
||||||
public string? HistoryLang{ get; set; }
|
public string? HistoryLang{ get; set; }
|
||||||
|
|
||||||
|
|
@ -111,6 +128,12 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("history_auto_refresh_mode")]
|
[JsonProperty("history_auto_refresh_mode")]
|
||||||
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
|
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("history_auto_refresh_add_to_queue")]
|
||||||
|
public bool HistoryAutoRefreshAddToQueue{ get; set; } = true;
|
||||||
|
|
||||||
|
[JsonProperty("tracked_series_release_last_check_utc")]
|
||||||
|
public DateTime? TrackedSeriesReleaseLastCheckUtc{ get; set; }
|
||||||
|
|
||||||
|
|
||||||
[JsonProperty("sonarr_properties")]
|
[JsonProperty("sonarr_properties")]
|
||||||
public SonarrProperties? SonarrProperties{ get; set; }
|
public SonarrProperties? SonarrProperties{ get; set; }
|
||||||
|
|
@ -224,6 +247,9 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("download_part_size")]
|
[JsonProperty("download_part_size")]
|
||||||
public int Partsize{ get; set; }
|
public int Partsize{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("dub_download_delay_seconds")]
|
||||||
|
public int DubDownloadDelaySeconds{ get; set; }
|
||||||
|
|
||||||
[JsonProperty("soft_subs")]
|
[JsonProperty("soft_subs")]
|
||||||
public List<string> DlSubs{ get; set; } =[];
|
public List<string> DlSubs{ get; set; } =[];
|
||||||
|
|
||||||
|
|
@ -317,6 +343,9 @@ public class CrDownloadOptions{
|
||||||
[JsonProperty("mux_sync_dubs")]
|
[JsonProperty("mux_sync_dubs")]
|
||||||
public bool SyncTiming{ get; set; }
|
public bool SyncTiming{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("mux_sync_fallback_full_quality")]
|
||||||
|
public bool SyncTimingFullQualityFallback{ get; set; }
|
||||||
|
|
||||||
[JsonProperty("mux_sync_hwaccel")]
|
[JsonProperty("mux_sync_hwaccel")]
|
||||||
public string? FfmpegHwAccelFlag{ get; set; }
|
public string? FfmpegHwAccelFlag{ get; set; }
|
||||||
|
|
||||||
|
|
@ -360,4 +389,46 @@ public class CrDownloadOptions{
|
||||||
public bool SearchFetchFeaturedMusic{ get; set; }
|
public bool SearchFetchFeaturedMusic{ get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
public void NormalizeNotificationSettings(){
|
||||||
|
NotificationSettings ??= new NotificationSettings();
|
||||||
|
|
||||||
|
var hasProviders = NotificationSettings.Providers.Count > 0;
|
||||||
|
|
||||||
|
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||||
|
if (hasProviders){
|
||||||
|
if (soundProvider.Events.Count == 0){
|
||||||
|
soundProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||||
|
}
|
||||||
|
} else{
|
||||||
|
soundProvider.Enabled = DownloadFinishedPlaySound;
|
||||||
|
soundProvider.Path = DownloadFinishedSoundPath ?? string.Empty;
|
||||||
|
soundProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||||
|
if (hasProviders){
|
||||||
|
if (executeProvider.Events.Count == 0){
|
||||||
|
executeProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||||
|
}
|
||||||
|
} else{
|
||||||
|
executeProvider.Enabled = DownloadFinishedExecute;
|
||||||
|
executeProvider.Path = DownloadFinishedExecutePath ?? string.Empty;
|
||||||
|
executeProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncLegacyNotificationFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SyncLegacyNotificationFields(){
|
||||||
|
NotificationSettings ??= new NotificationSettings();
|
||||||
|
|
||||||
|
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||||
|
DownloadFinishedPlaySound = soundProvider.Enabled;
|
||||||
|
DownloadFinishedSoundPath = soundProvider.Path;
|
||||||
|
|
||||||
|
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||||
|
DownloadFinishedExecute = executeProvider.Enabled;
|
||||||
|
DownloadFinishedExecutePath = executeProvider.Path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +246,12 @@ public class CrunchyEpisode : IHistorySource{
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsSpecialSeason(){
|
public bool IsSpecialSeason(){
|
||||||
|
if (SeasonTitle.Contains("OVA", StringComparison.Ordinal) ||
|
||||||
|
SeasonTitle.Contains("Special", StringComparison.Ordinal) ||
|
||||||
|
SeasonTitle.Contains("Extra", StringComparison.Ordinal)){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(Identifier)){
|
if (string.IsNullOrEmpty(Identifier)){
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +291,20 @@ public class CrunchyEpisode : IHistorySource{
|
||||||
}
|
}
|
||||||
|
|
||||||
public SeriesType GetSeriesType(){
|
public SeriesType GetSeriesType(){
|
||||||
|
if (string.IsNullOrWhiteSpace(Identifier))
|
||||||
return SeriesType.Series;
|
return SeriesType.Series;
|
||||||
|
|
||||||
|
var parts = Identifier.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return SeriesType.Series;
|
||||||
|
|
||||||
|
return parts[1] switch{
|
||||||
|
var p when p.StartsWith("S", StringComparison.OrdinalIgnoreCase) => SeriesType.Series,
|
||||||
|
var p when p.StartsWith("M", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||||
|
var p when p.StartsWith("T", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||||
|
var p when parts.Length == 2 && p.StartsWith("E", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||||
|
_ => SeriesType.Series
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public EpisodeType GetEpisodeType(){
|
public EpisodeType GetEpisodeType(){
|
||||||
|
|
@ -373,7 +392,6 @@ public class CrunchyEpMeta{
|
||||||
public string? AbsolutEpisodeNumberE{ get; set; }
|
public string? AbsolutEpisodeNumberE{ get; set; }
|
||||||
public string? Image{ get; set; }
|
public string? Image{ get; set; }
|
||||||
public string? ImageBig{ get; set; }
|
public string? ImageBig{ get; set; }
|
||||||
public bool Paused{ get; set; }
|
|
||||||
public DownloadProgress DownloadProgress{ get; set; } = new();
|
public DownloadProgress DownloadProgress{ get; set; } = new();
|
||||||
|
|
||||||
public List<string>? SelectedDubs{ get; set; }
|
public List<string>? SelectedDubs{ get; set; }
|
||||||
|
|
@ -385,6 +403,7 @@ public class CrunchyEpMeta{
|
||||||
public string? DownloadPath{ get; set; }
|
public string? DownloadPath{ get; set; }
|
||||||
public string? VideoQuality{ get; set; }
|
public string? VideoQuality{ get; set; }
|
||||||
public List<string> DownloadSubs{ get; set; } =[];
|
public List<string> DownloadSubs{ get; set; } =[];
|
||||||
|
public string? TempFileSuffix{ get; set; }
|
||||||
public bool Music{ get; set; }
|
public bool Music{ get; set; }
|
||||||
|
|
||||||
public string Resolution{ get; set; }
|
public string Resolution{ get; set; }
|
||||||
|
|
@ -399,18 +418,74 @@ public class CrunchyEpMeta{
|
||||||
|
|
||||||
public bool HighlightAllAvailable{ get; set; }
|
public bool HighlightAllAvailable{ get; set; }
|
||||||
|
|
||||||
public CancellationTokenSource Cts { get; } = new();
|
[JsonIgnore]
|
||||||
|
public CancellationTokenSource Cts { get; private set; } = new();
|
||||||
|
|
||||||
|
public void RenewCancellationToken(){
|
||||||
|
if (!Cts.IsCancellationRequested){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cts.Dispose();
|
||||||
|
Cts = new CancellationTokenSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CancelDownload(){
|
||||||
|
if (Cts.IsCancellationRequested){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cts.Cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DownloadProgress{
|
public class DownloadProgress{
|
||||||
public bool IsDownloading = false;
|
public DownloadState State{ get; set; } = DownloadState.Queued;
|
||||||
public bool Done = false;
|
public DownloadState ResumeState{ get; set; } = DownloadState.Downloading;
|
||||||
public bool Error = false;
|
|
||||||
public string Doing = string.Empty;
|
public string Doing = string.Empty;
|
||||||
|
public DateTimeOffset? RetryAtUtc{ get; set; }
|
||||||
|
public int RetryAttemptCount{ get; set; }
|
||||||
|
|
||||||
public int Percent{ get; set; }
|
public int Percent{ get; set; }
|
||||||
public double Time{ get; set; }
|
public double Time{ get; set; }
|
||||||
public double DownloadSpeedBytes{ get; set; }
|
public double DownloadSpeedBytes{ get; set; }
|
||||||
|
|
||||||
|
public bool IsQueued => State == DownloadState.Queued;
|
||||||
|
public bool IsDownloading => State == DownloadState.Downloading;
|
||||||
|
public bool IsPaused => State == DownloadState.Paused;
|
||||||
|
public bool IsProcessing => State == DownloadState.Processing;
|
||||||
|
public bool IsDone => State == DownloadState.Done;
|
||||||
|
public bool IsError => State == DownloadState.Error;
|
||||||
|
public bool IsFinished => State is DownloadState.Done or DownloadState.Error;
|
||||||
|
public bool IsRunnable => State is DownloadState.Queued or DownloadState.Error;
|
||||||
|
public bool IsWaitingForRetry => RetryAtUtc.HasValue && RetryAtUtc.Value > DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public void ResetForRetry(){
|
||||||
|
State = DownloadState.Queued;
|
||||||
|
ResumeState = DownloadState.Downloading;
|
||||||
|
Percent = 0;
|
||||||
|
Time = 0;
|
||||||
|
DownloadSpeedBytes = 0;
|
||||||
|
Doing = string.Empty;
|
||||||
|
RetryAtUtc = null;
|
||||||
|
RetryAttemptCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ScheduleRetry(TimeSpan delay, string doing){
|
||||||
|
State = DownloadState.Queued;
|
||||||
|
ResumeState = DownloadState.Downloading;
|
||||||
|
Percent = 0;
|
||||||
|
Time = 0;
|
||||||
|
DownloadSpeedBytes = 0;
|
||||||
|
Doing = doing;
|
||||||
|
RetryAtUtc = DateTimeOffset.UtcNow.Add(delay);
|
||||||
|
RetryAttemptCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearRetryState(){
|
||||||
|
RetryAtUtc = null;
|
||||||
|
RetryAttemptCount = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CrunchyEpMetaData{
|
public class CrunchyEpMetaData{
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,22 @@ namespace CRD.Utils.Structs.Crunchyroll;
|
||||||
|
|
||||||
public class StreamError{
|
public class StreamError{
|
||||||
[JsonPropertyName("error")]
|
[JsonPropertyName("error")]
|
||||||
public string Error{ get; set; }
|
public string? Error{ get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("activeStreams")]
|
[JsonPropertyName("activeStreams")]
|
||||||
public List<ActiveStream> ActiveStreams{ get; set; } = new ();
|
public List<ActiveStream> ActiveStreams{ get; set; } = new ();
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? RawJson{ get; set; }
|
||||||
|
|
||||||
public static StreamError? FromJson(string json){
|
public static StreamError? FromJson(string json){
|
||||||
try{
|
try{
|
||||||
return Helpers.Deserialize<StreamError>(json,null);
|
var error = Helpers.Deserialize<StreamError>(json,null);
|
||||||
|
if (error != null){
|
||||||
|
error.RawJson = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
} catch (Exception e){
|
} catch (Exception e){
|
||||||
Console.Error.WriteLine(e);
|
Console.Error.WriteLine(e);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -23,6 +31,14 @@ public class StreamError{
|
||||||
public bool IsTooManyActiveStreamsError(){
|
public bool IsTooManyActiveStreamsError(){
|
||||||
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
|
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsRateLimitError(){
|
||||||
|
return IsPlaybackRateLimitError();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPlaybackRateLimitError(){
|
||||||
|
return Error?.Contains("4294") == true || RawJson?.Contains("4294") == true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ActiveStream{
|
public class ActiveStream{
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,8 @@ public class DownloadResponse{
|
||||||
public string VideoTitle{ get; set; }
|
public string VideoTitle{ get; set; }
|
||||||
public bool Error{ get; set; }
|
public bool Error{ get; set; }
|
||||||
public string ErrorText{ get; set; }
|
public string ErrorText{ get; set; }
|
||||||
|
public bool RetrySuggested{ get; set; }
|
||||||
|
public int RetryDelaySeconds{ get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DownloadedMedia : SxItem{
|
public class DownloadedMedia : SxItem{
|
||||||
|
|
@ -133,6 +135,7 @@ public class DownloadedMedia : SxItem{
|
||||||
public bool IsPrimary{ get; set; }
|
public bool IsPrimary{ get; set; }
|
||||||
|
|
||||||
public int bitrate{ get; set; }
|
public int bitrate{ get; set; }
|
||||||
|
public int? Delay{ get; set; }
|
||||||
public bool? Cc{ get; set; }
|
public bool? Cc{ get; set; }
|
||||||
public bool? Signs{ get; set; }
|
public bool? Signs{ get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
||||||
[JsonProperty("episode_was_downloaded")]
|
[JsonProperty("episode_was_downloaded")]
|
||||||
public bool WasDownloaded{ get; set; }
|
public bool WasDownloaded{ get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("episode_tracked_series_release_notified")]
|
||||||
|
public bool TrackedSeriesReleaseNotified{ get; set; }
|
||||||
|
|
||||||
[JsonProperty("episode_special_episode")]
|
[JsonProperty("episode_special_episode")]
|
||||||
public bool SpecialEpisode{ get; set; }
|
public bool SpecialEpisode{ get; set; }
|
||||||
|
|
||||||
|
|
@ -43,6 +46,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
||||||
[JsonProperty("episode_type")]
|
[JsonProperty("episode_type")]
|
||||||
public EpisodeType EpisodeType{ get; set; } = EpisodeType.Unknown;
|
public EpisodeType EpisodeType{ get; set; } = EpisodeType.Unknown;
|
||||||
|
|
||||||
|
[JsonProperty("episode_series_type")]
|
||||||
|
public SeriesType EpisodeSeriesType{ get; set; } = SeriesType.Unknown;
|
||||||
|
|
||||||
[JsonProperty("episode_thumbnail_url")]
|
[JsonProperty("episode_thumbnail_url")]
|
||||||
public string? ThumbnailImageUrl{ get; set; }
|
public string? ThumbnailImageUrl{ get; set; }
|
||||||
|
|
||||||
|
|
@ -86,7 +92,7 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
||||||
public Bitmap? ThumbnailImage{ get; set; }
|
public Bitmap? ThumbnailImage{ get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool IsImageLoaded{ get; private set; } = false;
|
public bool IsImageLoaded{ get; private set; }
|
||||||
|
|
||||||
public async Task LoadImage(){
|
public async Task LoadImage(){
|
||||||
if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl))
|
if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl))
|
||||||
|
|
@ -153,15 +159,15 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
||||||
|
|
||||||
switch (EpisodeType){
|
switch (EpisodeType){
|
||||||
case EpisodeType.MusicVideo:
|
case EpisodeType.MusicVideo:
|
||||||
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
await CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||||
break;
|
break;
|
||||||
case EpisodeType.Concert:
|
case EpisodeType.Concert:
|
||||||
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
await CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||||
break;
|
break;
|
||||||
case EpisodeType.Episode:
|
case EpisodeType.Episode:
|
||||||
case EpisodeType.Unknown:
|
case EpisodeType.Unknown:
|
||||||
default:
|
default:
|
||||||
await QueueManager.Instance.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
|
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
|
||||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang,
|
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang,
|
||||||
CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode);
|
CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ public class HistorySeason : INotifyPropertyChanged{
|
||||||
public StringItem? _selectedVideoQualityItem;
|
public StringItem? _selectedVideoQualityItem;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
private bool Loading = false;
|
private bool Loading;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public StringItem? SelectedVideoQualityItem{
|
public StringItem? SelectedVideoQualityItem{
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
||||||
public Bitmap? ThumbnailImage{ get; set; }
|
public Bitmap? ThumbnailImage{ get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool IsImageLoaded{ get; private set; } = false;
|
public bool IsImageLoaded{ get; private set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool FetchingData{ get; set; }
|
public bool FetchingData{ get; set; }
|
||||||
|
|
@ -112,7 +112,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
||||||
#region Settings Override
|
#region Settings Override
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
private bool Loading = false;
|
private bool Loading;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public StringItem? _selectedVideoQualityItem;
|
public StringItem? _selectedVideoQualityItem;
|
||||||
|
|
@ -350,6 +350,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
case SeriesType.Movie:
|
||||||
case SeriesType.Series:
|
case SeriesType.Series:
|
||||||
case SeriesType.Unknown:
|
case SeriesType.Unknown:
|
||||||
default:
|
default:
|
||||||
|
|
@ -396,6 +397,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
||||||
case SeriesType.Artist:
|
case SeriesType.Artist:
|
||||||
Helpers.OpenUrl($"https://www.crunchyroll.com/artist/{SeriesId}");
|
Helpers.OpenUrl($"https://www.crunchyroll.com/artist/{SeriesId}");
|
||||||
break;
|
break;
|
||||||
|
case SeriesType.Movie:
|
||||||
case SeriesType.Series:
|
case SeriesType.Series:
|
||||||
case SeriesType.Unknown:
|
case SeriesType.Unknown:
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ using CRD.Downloader;
|
||||||
using CRD.Downloader.Crunchyroll;
|
using CRD.Downloader.Crunchyroll;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
using CRD.Utils.Http;
|
using CRD.Utils.Http;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NuGet.Versioning;
|
using NuGet.Versioning;
|
||||||
|
|
||||||
|
|
@ -24,6 +25,7 @@ public class Updater : ObservableObject{
|
||||||
public double Progress;
|
public double Progress;
|
||||||
public bool Failed;
|
public bool Failed;
|
||||||
public string LatestVersion = "";
|
public string LatestVersion = "";
|
||||||
|
public List<GithubJson> GhAuthJson = [];
|
||||||
|
|
||||||
public static Updater Instance{ get; } = new();
|
public static Updater Instance{ get; } = new();
|
||||||
|
|
||||||
|
|
@ -109,12 +111,19 @@ public class Updater : ObservableObject{
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadUrl = asset.BrowserDownloadUrl;
|
downloadUrl = asset.BrowserDownloadUrl;
|
||||||
|
await NotificationPublisher.Instance.PublishUpdateAvailableAsync(
|
||||||
|
CrunchyrollManager.Instance.CrunOptions.NotificationSettings,
|
||||||
|
currentVersion.ToString(),
|
||||||
|
selectedRelease.TagName,
|
||||||
|
platformName,
|
||||||
|
downloadUrl);
|
||||||
|
|
||||||
_ = UpdateChangelogAsync();
|
_ = UpdateChangelogAsync();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("No updates available.");
|
Console.WriteLine("No updates available.");
|
||||||
|
NotificationPublisher.Instance.ResetUpdateAvailableNotification();
|
||||||
_ = UpdateChangelogAsync();
|
_ = UpdateChangelogAsync();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +133,25 @@ public class Updater : ObservableObject{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CheckGhJsonAsync(){
|
||||||
|
var url = "https://Crunchy-DL.github.io/Crunchy-Downloader/data.json";
|
||||||
|
try{
|
||||||
|
HttpClientHandler handler = new HttpClientHandler();
|
||||||
|
handler.UseProxy = false;
|
||||||
|
|
||||||
|
using (var client = new HttpClient(handler)){
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
|
||||||
|
var response = await client.GetStringAsync(url);
|
||||||
|
var authList = Helpers.Deserialize<List<GithubJson>>(response, null);
|
||||||
|
if (authList is{ Count: > 0 }){
|
||||||
|
GhAuthJson = authList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e){
|
||||||
|
Console.Error.WriteLine("Failed to get GH CR Auth information");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task UpdateChangelogAsync(){
|
public async Task UpdateChangelogAsync(){
|
||||||
var client = HttpClientReq.Instance.GetHttpClient();
|
var client = HttpClientReq.Instance.GetHttpClient();
|
||||||
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
|
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
|
||||||
|
|
@ -138,7 +166,15 @@ public class Updater : ObservableObject{
|
||||||
LatestVersion = "v1.0.0";
|
LatestVersion = "v1.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingVersion == LatestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(LatestVersion.TrimStart('v'))){
|
if (!NuGetVersion.TryParse(existingVersion.TrimStart('v'), out var existingNuGetVersion)){
|
||||||
|
existingNuGetVersion = NuGetVersion.Parse("1.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!NuGetVersion.TryParse(LatestVersion.TrimStart('v'), out var latestNuGetVersion)){
|
||||||
|
latestNuGetVersion = NuGetVersion.Parse("1.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingNuGetVersion >= latestNuGetVersion){
|
||||||
Console.WriteLine("CHANGELOG.md is already up to date.");
|
Console.WriteLine("CHANGELOG.md is already up to date.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -180,10 +216,16 @@ public class Updater : ObservableObject{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
||||||
string[] lines = File.ReadAllLines(changelogFilePath);
|
string[] lines = File.ReadAllLines(changelogFilePath);
|
||||||
|
|
||||||
foreach (string line in lines){
|
foreach (string line in lines){
|
||||||
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]");
|
Match match = Regex.Match(line, @"^## \[(v?[^\]]+)\]");
|
||||||
if (match.Success)
|
if (!match.Success)
|
||||||
return match.Groups[1].Value;
|
continue;
|
||||||
|
|
||||||
|
string versionText = match.Groups[1].Value;
|
||||||
|
|
||||||
|
if (NuGetVersion.TryParse(versionText.TrimStart('v'), out _))
|
||||||
|
return versionText;
|
||||||
}
|
}
|
||||||
|
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
|
@ -313,6 +355,18 @@ public class Updater : ObservableObject{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class GithubJson{
|
||||||
|
[JsonProperty("type")]
|
||||||
|
public string Type{ get; set; } = string.Empty;
|
||||||
|
[JsonProperty("version_name")]
|
||||||
|
public string VersionName{ get; set; } = string.Empty;
|
||||||
|
[JsonProperty("version_code")]
|
||||||
|
public string VersionCode{ get; set; } = string.Empty;
|
||||||
|
[JsonProperty("Authorization")]
|
||||||
|
public string Authorization{ get; set; } = string.Empty;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
public class GithubRelease{
|
public class GithubRelease{
|
||||||
[JsonProperty("tag_name")]
|
[JsonProperty("tag_name")]
|
||||||
public string TagName{ get; set; } = string.Empty;
|
public string TagName{ get; set; } = string.Empty;
|
||||||
|
|
@ -370,7 +424,5 @@ public class Updater : ObservableObject{
|
||||||
public bool IsForPlatform(string platform){
|
public bool IsForPlatform(string platform){
|
||||||
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
|
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -12,11 +12,10 @@ using CRD.Utils.UI;
|
||||||
using CRD.ViewModels.Utils;
|
using CRD.ViewModels.Utils;
|
||||||
using CRD.Views.Utils;
|
using CRD.Views.Utils;
|
||||||
using FluentAvalonia.UI.Controls;
|
using FluentAvalonia.UI.Controls;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace CRD.ViewModels;
|
namespace CRD.ViewModels;
|
||||||
|
|
||||||
public partial class AccountPageViewModel : ViewModelBase{
|
public partial class AccountPageViewModel : ViewModelBase, IDisposable{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Bitmap? _profileImage;
|
private Bitmap? _profileImage;
|
||||||
|
|
||||||
|
|
@ -32,7 +31,9 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _remainingTime = "";
|
private string _remainingTime = "";
|
||||||
|
|
||||||
private static DispatcherTimer? _timer;
|
private static AccountPageViewModel? _activeInstance;
|
||||||
|
|
||||||
|
private readonly DispatcherTimer _timer;
|
||||||
private DateTime _targetTime;
|
private DateTime _targetTime;
|
||||||
|
|
||||||
private bool IsCancelled;
|
private bool IsCancelled;
|
||||||
|
|
@ -40,6 +41,14 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
private bool EndedButMaybeActive;
|
private bool EndedButMaybeActive;
|
||||||
|
|
||||||
public AccountPageViewModel(){
|
public AccountPageViewModel(){
|
||||||
|
_activeInstance?.StopSubscriptionTimer();
|
||||||
|
_activeInstance = this;
|
||||||
|
|
||||||
|
_timer = new DispatcherTimer{
|
||||||
|
Interval = TimeSpan.FromSeconds(1)
|
||||||
|
};
|
||||||
|
_timer.Tick += Timer_Tick;
|
||||||
|
|
||||||
UpdatetProfile();
|
UpdatetProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +56,7 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
var remaining = _targetTime - DateTime.Now;
|
var remaining = _targetTime - DateTime.Now;
|
||||||
if (remaining <= TimeSpan.Zero){
|
if (remaining <= TimeSpan.Zero){
|
||||||
RemainingTime = "No active Subscription";
|
RemainingTime = "No active Subscription";
|
||||||
_timer?.Stop();
|
_timer.Stop();
|
||||||
if (UnknownEndDate){
|
if (UnknownEndDate){
|
||||||
RemainingTime = "Unknown Subscription end date";
|
RemainingTime = "Unknown Subscription end date";
|
||||||
}
|
}
|
||||||
|
|
@ -55,30 +64,32 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
if (EndedButMaybeActive){
|
if (EndedButMaybeActive){
|
||||||
RemainingTime = "Subscription maybe ended";
|
RemainingTime = "Subscription maybe ended";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
|
|
||||||
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
|
|
||||||
}
|
|
||||||
} else{
|
} else{
|
||||||
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
|
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdatetProfile(){
|
public void UpdatetProfile(){
|
||||||
|
StopSubscriptionTimer();
|
||||||
|
IsCancelled = false;
|
||||||
|
UnknownEndDate = false;
|
||||||
|
EndedButMaybeActive = false;
|
||||||
|
RemainingTime = "No active Subscription";
|
||||||
|
|
||||||
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
|
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
|
||||||
var firstEndpointProfile = firstEndpoint.Profile;
|
var firstEndpointProfile = firstEndpoint.Profile;
|
||||||
|
|
||||||
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
|
var isLoggedIn = firstEndpointProfile.Username != "???";
|
||||||
|
HasMultiProfile = isLoggedIn && firstEndpoint.MultiProfile.Profiles.Count > 1;
|
||||||
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
|
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
|
||||||
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
|
LoginLogoutText = isLoggedIn ? "Logout" : "Login"; // Default state
|
||||||
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
|
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
|
||||||
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
|
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
|
||||||
|
|
||||||
|
|
||||||
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
|
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
|
||||||
|
|
||||||
if (subscriptions != null){
|
if (subscriptions != null && HasSubscriptionData(subscriptions)){
|
||||||
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
|
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
|
||||||
var sub = subscriptions.SubscriptionProducts.First();
|
var sub = subscriptions.SubscriptionProducts.First();
|
||||||
IsCancelled = sub.IsCancelled;
|
IsCancelled = sub.IsCancelled;
|
||||||
|
|
@ -97,23 +108,8 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
|
|
||||||
if (!UnknownEndDate){
|
if (!UnknownEndDate){
|
||||||
_targetTime = subscriptions.NextRenewalDate;
|
_targetTime = subscriptions.NextRenewalDate;
|
||||||
_timer = new DispatcherTimer{
|
|
||||||
Interval = TimeSpan.FromSeconds(1)
|
|
||||||
};
|
|
||||||
_timer.Tick += Timer_Tick;
|
|
||||||
_timer.Start();
|
_timer.Start();
|
||||||
}
|
Timer_Tick(null, EventArgs.Empty);
|
||||||
} else{
|
|
||||||
RemainingTime = "No active Subscription";
|
|
||||||
if (_timer != null){
|
|
||||||
_timer.Stop();
|
|
||||||
_timer.Tick -= Timer_Tick;
|
|
||||||
}
|
|
||||||
|
|
||||||
RaisePropertyChanged(nameof(RemainingTime));
|
|
||||||
|
|
||||||
if (subscriptions != null){
|
|
||||||
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +122,26 @@ public partial class AccountPageViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasSubscriptionData(CRD.Utils.Structs.Crunchyroll.Subscription subscriptions){
|
||||||
|
return subscriptions.SubscriptionProducts is{ Count: > 0 } ||
|
||||||
|
subscriptions.ThirdPartySubscriptionProducts is{ Count: > 0 } ||
|
||||||
|
subscriptions.NonrecurringSubscriptionProducts is{ Count: > 0 } ||
|
||||||
|
subscriptions.FunimationSubscriptions is{ Count: > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopSubscriptionTimer(){
|
||||||
|
_timer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose(){
|
||||||
|
_timer.Tick -= Timer_Tick;
|
||||||
|
StopSubscriptionTimer();
|
||||||
|
|
||||||
|
if (ReferenceEquals(_activeInstance, this)){
|
||||||
|
_activeInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task Button_Press(){
|
public async Task Button_Press(){
|
||||||
if (LoginLogoutText == "Login"){
|
if (LoginLogoutText == "Login"){
|
||||||
|
|
|
||||||
|
|
@ -207,14 +207,14 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
||||||
|
|
||||||
if (music != null){
|
if (music != null){
|
||||||
var meta = musicClass.EpisodeMeta(music);
|
var meta = musicClass.EpisodeMeta(music);
|
||||||
QueueManager.Instance.CrAddMusicMetaToQueue(meta);
|
CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (AddAllEpisodes){
|
} else if (AddAllEpisodes){
|
||||||
var musicClass = CrunchyrollManager.Instance.CrMusic;
|
var musicClass = CrunchyrollManager.Instance.CrMusic;
|
||||||
if (currentMusicVideoList == null) return;
|
if (currentMusicVideoList == null) return;
|
||||||
foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){
|
foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){
|
||||||
QueueManager.Instance.CrAddMusicMetaToQueue(meta);
|
CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +223,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
||||||
AddItemsToSelectedEpisodes();
|
AddItemsToSelectedEpisodes();
|
||||||
|
|
||||||
if (currentSeriesList != null){
|
if (currentSeriesList != null){
|
||||||
await QueueManager.Instance.CrAddSeriesToQueue(
|
await CrunchyrollManager.Instance.CrQueue.CrAddSeriesToQueue(
|
||||||
currentSeriesList,
|
currentSeriesList,
|
||||||
new CrunchyMultiDownload(
|
new CrunchyMultiDownload(
|
||||||
CrunchyrollManager.Instance.CrunOptions.DubLang,
|
CrunchyrollManager.Instance.CrunOptions.DubLang,
|
||||||
|
|
@ -327,17 +327,17 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleMusicVideoUrl(string id){
|
private void HandleMusicVideoUrl(string id){
|
||||||
_ = QueueManager.Instance.CrAddMusicVideoToQueue(id);
|
_ = CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(id);
|
||||||
ResetState();
|
ResetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleConcertUrl(string id){
|
private void HandleConcertUrl(string id){
|
||||||
_ = QueueManager.Instance.CrAddConcertToQueue(id);
|
_ = CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(id);
|
||||||
ResetState();
|
ResetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleEpisodeUrl(string locale, string id){
|
private void HandleEpisodeUrl(string locale, string id){
|
||||||
_ = QueueManager.Instance.CrAddEpisodeToQueue(
|
_ = CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
|
||||||
id, DetermineLocale(locale),
|
id, DetermineLocale(locale),
|
||||||
CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
||||||
ResetState();
|
ResetState();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
@ -20,16 +21,16 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
||||||
public ObservableCollection<DownloadItemModel> Items{ get; }
|
public ObservableCollection<DownloadItemModel> Items{ get; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shutdownWhenQueueEmpty;
|
private bool shutdownWhenQueueEmpty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _autoDownload;
|
private bool autoDownload;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _removeFinished;
|
private bool removeFinished;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private QueueManager _queueManagerIns;
|
private QueueManager queueManagerIns;
|
||||||
|
|
||||||
public DownloadsPageViewModel(){
|
public DownloadsPageViewModel(){
|
||||||
QueueManagerIns = QueueManager.Instance;
|
QueueManagerIns = QueueManager.Instance;
|
||||||
|
|
@ -63,10 +64,10 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ClearQueue(){
|
public void ClearQueue(){
|
||||||
var items = QueueManagerIns.Queue;
|
var items = QueueManagerIns.Queue;
|
||||||
QueueManagerIns.Queue.Clear();
|
QueueManagerIns.ClearQueue();
|
||||||
|
|
||||||
foreach (var crunchyEpMeta in items){
|
foreach (var crunchyEpMeta in items){
|
||||||
if (!crunchyEpMeta.DownloadProgress.Done){
|
if (!crunchyEpMeta.DownloadProgress.IsDone){
|
||||||
foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){
|
foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){
|
||||||
try{
|
try{
|
||||||
if (File.Exists(downloadItemDownloadedFile)){
|
if (File.Exists(downloadItemDownloadedFile)){
|
||||||
|
|
@ -85,13 +86,26 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
||||||
var items = QueueManagerIns.Queue;
|
var items = QueueManagerIns.Queue;
|
||||||
|
|
||||||
foreach (var crunchyEpMeta in items){
|
foreach (var crunchyEpMeta in items){
|
||||||
if (crunchyEpMeta.DownloadProgress.Error){
|
if (crunchyEpMeta.DownloadProgress.IsError){
|
||||||
crunchyEpMeta.DownloadProgress = new();
|
crunchyEpMeta.DownloadProgress.ResetForRetry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QueueManagerIns.UpdateDownloadListItems();
|
QueueManagerIns.UpdateDownloadListItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void PauseQueue(){
|
||||||
|
AutoDownload = false;
|
||||||
|
foreach (var item in Items){
|
||||||
|
if (item.epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
|
||||||
|
item.ToggleIsDownloading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueManagerIns.UpdateDownloadListItems();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class DownloadItemModel : INotifyPropertyChanged{
|
public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
|
|
@ -113,6 +127,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
|
|
||||||
|
|
||||||
public bool Error{ get; set; }
|
public bool Error{ get; set; }
|
||||||
|
public bool ShowPauseIcon{ get; set; }
|
||||||
|
|
||||||
public DownloadItemModel(CrunchyEpMeta epMetaF){
|
public DownloadItemModel(CrunchyEpMeta epMetaF){
|
||||||
epMeta = epMetaF;
|
epMeta = epMetaF;
|
||||||
|
|
@ -121,20 +136,14 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
Title = epMeta.SeriesTitle + (!string.IsNullOrEmpty(epMeta.Season) ? " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) : "") + " - " +
|
Title = epMeta.SeriesTitle + (!string.IsNullOrEmpty(epMeta.Season) ? " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) : "") + " - " +
|
||||||
epMeta.EpisodeTitle;
|
epMeta.EpisodeTitle;
|
||||||
|
|
||||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
Done = epMeta.DownloadProgress.IsDone;
|
||||||
|
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||||
Done = epMeta.DownloadProgress.Done;
|
ShowPauseIcon = isDownloading;
|
||||||
Percent = epMeta.DownloadProgress.Percent;
|
Percent = epMeta.DownloadProgress.Percent;
|
||||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
Time = GetTimeText();
|
||||||
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
DownloadSpeed = GetDownloadSpeedText();
|
||||||
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
Paused = epMeta.DownloadProgress.IsPaused;
|
||||||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
DoingWhat = GetDoingWhatText();
|
||||||
|
|
||||||
;
|
|
||||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
|
||||||
DoingWhat = epMeta.Paused ? "Paused" :
|
|
||||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
|
||||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
|
||||||
|
|
||||||
InfoText = JoinWithSeparator(
|
InfoText = JoinWithSeparator(
|
||||||
GetDubString(),
|
GetDubString(),
|
||||||
|
|
@ -143,7 +152,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
);
|
);
|
||||||
InfoTextHover = epMeta.AvailableQualities;
|
InfoTextHover = epMeta.AvailableQualities;
|
||||||
|
|
||||||
Error = epMeta.DownloadProgress.Error;
|
Error = epMeta.DownloadProgress.IsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
string JoinWithSeparator(params string[] parts){
|
string JoinWithSeparator(params string[] parts){
|
||||||
|
|
@ -191,18 +200,15 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Refresh(){
|
public void Refresh(){
|
||||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
Done = epMeta.DownloadProgress.IsDone;
|
||||||
Done = epMeta.DownloadProgress.Done;
|
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||||
|
ShowPauseIcon = isDownloading;
|
||||||
Percent = epMeta.DownloadProgress.Percent;
|
Percent = epMeta.DownloadProgress.Percent;
|
||||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
Time = GetTimeText();
|
||||||
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
DownloadSpeed = GetDownloadSpeedText();
|
||||||
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
|
||||||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
|
||||||
|
|
||||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
Paused = epMeta.DownloadProgress.IsPaused;
|
||||||
DoingWhat = epMeta.Paused ? "Paused" :
|
DoingWhat = GetDoingWhatText();
|
||||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
|
||||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
|
||||||
|
|
||||||
InfoText = JoinWithSeparator(
|
InfoText = JoinWithSeparator(
|
||||||
GetDubString(),
|
GetDubString(),
|
||||||
|
|
@ -210,11 +216,12 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
epMeta.Resolution
|
epMeta.Resolution
|
||||||
);
|
);
|
||||||
InfoTextHover = epMeta.AvailableQualities;
|
InfoTextHover = epMeta.AvailableQualities;
|
||||||
Error = epMeta.DownloadProgress.Error;
|
Error = epMeta.DownloadProgress.IsError;
|
||||||
|
|
||||||
|
|
||||||
if (PropertyChanged != null){
|
if (PropertyChanged != null){
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
||||||
|
|
@ -228,28 +235,90 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private string GetDoingWhatText(){
|
||||||
|
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
|
||||||
|
return "Rate limited, retrying at " + epMeta.DownloadProgress.RetryAtUtc.Value
|
||||||
|
.ToLocalTime()
|
||||||
|
.ToString("T", CultureInfo.CurrentCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Paused ? "Paused" :
|
||||||
|
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||||
|
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetTimeText(){
|
||||||
|
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDownloadSpeedText(){
|
||||||
|
if (epMeta.DownloadProgress.IsWaitingForRetry){
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||||
|
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||||
|
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ToggleIsDownloading(){
|
public void ToggleIsDownloading(){
|
||||||
if (isDownloading){
|
if (epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
|
||||||
//StopDownload();
|
epMeta.DownloadProgress.ResumeState = epMeta.DownloadProgress.State;
|
||||||
epMeta.Paused = !epMeta.Paused;
|
epMeta.DownloadProgress.State = DownloadState.Paused;
|
||||||
|
isDownloading = false;
|
||||||
|
Paused = true;
|
||||||
|
ShowPauseIcon = false;
|
||||||
|
|
||||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||||
} else{
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||||
if (epMeta.Paused){
|
|
||||||
epMeta.Paused = false;
|
QueueManager.Instance.ReleaseDownloadSlot(epMeta);
|
||||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (epMeta.DownloadProgress.IsPaused){
|
||||||
|
if (!QueueManager.Instance.TryResumeDownload(epMeta))
|
||||||
|
return;
|
||||||
|
|
||||||
|
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(Paused)));
|
||||||
} else{
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
StartDownload();
|
StartDownload();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void RetryDownload(){
|
||||||
|
epMeta.DownloadProgress.ResetForRetry();
|
||||||
|
isDownloading = false;
|
||||||
|
Paused = false;
|
||||||
|
ShowPauseIcon = false;
|
||||||
|
|
||||||
if (PropertyChanged != null){
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||||
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading"));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||||
}
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
|
||||||
|
|
||||||
|
QueueManager.Instance.RefreshQueue();
|
||||||
|
StartDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartDownload(){
|
public Task StartDownload(){
|
||||||
|
|
@ -261,28 +330,34 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||||
if (isDownloading)
|
if (isDownloading)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
epMeta.RenewCancellationToken();
|
||||||
isDownloading = true;
|
isDownloading = true;
|
||||||
epMeta.DownloadProgress.IsDownloading = true;
|
epMeta.DownloadProgress.State = DownloadState.Downloading;
|
||||||
|
Paused = false;
|
||||||
|
ShowPauseIcon = true;
|
||||||
|
|
||||||
Paused = epMeta.Paused;
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||||
|
|
||||||
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||||
|
|
||||||
if (epMeta.OnlySubs){
|
if (epMeta.OnlySubs){
|
||||||
newOptions.Novids = true;
|
newOptions?.Novids = true;
|
||||||
newOptions.Noaudio = true;
|
newOptions?.Noaudio = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions);
|
await CrunchyrollManager.Instance.DownloadEpisode(
|
||||||
|
epMeta,
|
||||||
|
epMeta.DownloadSettings ?? newOptions ?? CrunchyrollManager.Instance.CrunOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void RemoveFromQueue(){
|
public void RemoveFromQueue(){
|
||||||
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
|
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
|
||||||
if (downloadItem != null){
|
if (downloadItem != null){
|
||||||
QueueManager.Instance.Queue.Remove(downloadItem);
|
QueueManager.Instance.RemoveFromQueue(downloadItem);
|
||||||
epMeta.Cts.Cancel();
|
epMeta.CancelDownload();
|
||||||
if (!Done){
|
if (!Done){
|
||||||
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
|
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
|
||||||
try{
|
try{
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _showArtists;
|
private bool _showArtists;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showMovies = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private static bool _viewSelectionOpen;
|
private static bool _viewSelectionOpen;
|
||||||
|
|
||||||
|
|
@ -160,6 +163,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
||||||
SortDir = properties?.Ascending ?? false;
|
SortDir = properties?.Ascending ?? false;
|
||||||
ShowSeries = properties?.ShowSeries ?? true;
|
ShowSeries = properties?.ShowSeries ?? true;
|
||||||
ShowArtists = properties?.ShowArtists ?? false;
|
ShowArtists = properties?.ShowArtists ?? false;
|
||||||
|
ShowMovies = properties?.ShowMovies ?? true;
|
||||||
|
|
||||||
foreach (HistoryViewType viewType in Enum.GetValues(typeof(HistoryViewType))){
|
foreach (HistoryViewType viewType in Enum.GetValues(typeof(HistoryViewType))){
|
||||||
var combobox = new ComboBoxItem{ Content = viewType };
|
var combobox = new ComboBoxItem{ Content = viewType };
|
||||||
|
|
@ -274,6 +278,14 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
||||||
ApplyFilter();
|
ApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnShowMoviesChanged(bool value){
|
||||||
|
if (CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties != null) CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties.ShowMovies = ShowMovies;
|
||||||
|
|
||||||
|
CfgManager.WriteCrSettings();
|
||||||
|
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
partial void OnSelectedFilterChanged(FilterListElement? value){
|
partial void OnSelectedFilterChanged(FilterListElement? value){
|
||||||
if (value == null){
|
if (value == null){
|
||||||
|
|
@ -333,6 +345,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
||||||
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
|
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ShowMovies){
|
||||||
|
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Movie);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(SearchInput)){
|
if (!string.IsNullOrWhiteSpace(SearchInput)){
|
||||||
var tokens = SearchInput
|
var tokens = SearchInput
|
||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
@ -662,6 +678,7 @@ public class HistoryPageProperties{
|
||||||
|
|
||||||
public bool ShowSeries{ get; set; } = true;
|
public bool ShowSeries{ get; set; } = true;
|
||||||
public bool ShowArtists{ get; set; } = true;
|
public bool ShowArtists{ get; set; } = true;
|
||||||
|
public bool ShowMovies{ get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeasonsPageProperties{
|
public class SeasonsPageProperties{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
|
@ -7,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using CRD.Utils.Ffmpeg_Encoding;
|
using CRD.Utils.Ffmpeg_Encoding;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
|
using CRD.Utils;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Views;
|
using CRD.Views;
|
||||||
using DynamicData;
|
using DynamicData;
|
||||||
|
|
@ -19,61 +21,84 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
private readonly ContentDialog dialog;
|
private readonly ContentDialog dialog;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _editMode;
|
private bool editMode;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _presetName;
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private string presetName;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _codec;
|
[NotifyPropertyChangedFor(nameof(HasCodec))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private string codec;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ComboBoxItem _selectedResolution = new();
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private StringItemWithDisplayName selectedResolution = new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" };
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double? _crf = 23;
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private double? crf = 23;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _frameRate = "";
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private string frameRate = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _additionalParametersString = "";
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private string additionalParametersString = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ObservableCollection<StringItem> _additionalParameters = new();
|
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||||
|
private ObservableCollection<StringItem> additionalParameters = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private VideoPreset? _selectedCustomPreset;
|
private VideoPreset? selectedCustomPreset;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _fileExists;
|
private bool fileExists;
|
||||||
|
|
||||||
|
public bool HasCodec => !string.IsNullOrWhiteSpace(Codec);
|
||||||
|
public string CommandPreview => BuildCommandPreview();
|
||||||
|
|
||||||
public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
|
public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
|
||||||
|
|
||||||
public ObservableCollection<ComboBoxItem> ResolutionList{ get; } = new(){
|
public ObservableCollection<StringItemWithDisplayName> ResolutionList{ get; } = new(){
|
||||||
new ComboBoxItem(){ Content = "3840:2160" }, // 4K UHD
|
new(){ value = "3840:2160", DisplayName = "4K exact (3840:2160)" },
|
||||||
new ComboBoxItem(){ Content = "3440:1440" }, // Ultra-Wide Quad HD
|
new(){ value = "-2:2160", DisplayName = "4K keep AR (-2:2160)" },
|
||||||
new ComboBoxItem(){ Content = "2560:1440" }, // 1440p
|
new(){ value = "3440:1440", DisplayName = "UWQHD exact (3440:1440)" },
|
||||||
new ComboBoxItem(){ Content = "2560:1080" }, // Ultra-Wide Full HD
|
new(){ value = "2560:1440", DisplayName = "1440p exact (2560:1440)" },
|
||||||
new ComboBoxItem(){ Content = "2160:1080" }, // 2:1 Aspect Ratio
|
new(){ value = "-2:1440", DisplayName = "1440p keep AR (-2:1440)" },
|
||||||
new ComboBoxItem(){ Content = "1920:1080" }, // 1080p Full HD
|
new(){ value = "2560:1080", DisplayName = "UW FHD exact (2560:1080)" },
|
||||||
new ComboBoxItem(){ Content = "1920:800" }, // Cinematic 2.40:1
|
new(){ value = "2160:1080", DisplayName = "2:1 exact (2160:1080)" },
|
||||||
new ComboBoxItem(){ Content = "1600:900" }, // 900p
|
new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" },
|
||||||
new ComboBoxItem(){ Content = "1366:768" }, // 768p
|
new(){ value = "-2:1080", DisplayName = "1080p keep AR (-2:1080)" },
|
||||||
new ComboBoxItem(){ Content = "1280:960" }, // SXGA 4:3
|
new(){ value = "1920:800", DisplayName = "Cinema exact (1920:800)" },
|
||||||
new ComboBoxItem(){ Content = "1280:720" }, // 720p HD
|
new(){ value = "1600:900", DisplayName = "900p exact (1600:900)" },
|
||||||
new ComboBoxItem(){ Content = "1024:576" }, // 576p
|
new(){ value = "1366:768", DisplayName = "768p exact (1366:768)" },
|
||||||
new ComboBoxItem(){ Content = "960:540" }, // 540p qHD
|
new(){ value = "1280:960", DisplayName = "SXGA exact (1280:960)" },
|
||||||
new ComboBoxItem(){ Content = "854:480" }, // 480p
|
new(){ value = "1280:720", DisplayName = "720p exact (1280:720)" },
|
||||||
new ComboBoxItem(){ Content = "800:600" }, // SVGA
|
new(){ value = "-2:720", DisplayName = "720p keep AR (-2:720)" },
|
||||||
new ComboBoxItem(){ Content = "768:432" }, // 432p
|
new(){ value = "1024:576", DisplayName = "576p exact (1024:576)" },
|
||||||
new ComboBoxItem(){ Content = "720:480" }, // NTSC SD
|
new(){ value = "-2:576", DisplayName = "576p keep AR (-2:576)" },
|
||||||
new ComboBoxItem(){ Content = "704:576" }, // PAL SD
|
new(){ value = "960:540", DisplayName = "540p exact (960:540)" },
|
||||||
new ComboBoxItem(){ Content = "640:360" }, // 360p
|
new(){ value = "-2:540", DisplayName = "540p keep AR (-2:540)" },
|
||||||
new ComboBoxItem(){ Content = "426:240" }, // 240p
|
new(){ value = "854:480", DisplayName = "480p exact (854:480)" },
|
||||||
new ComboBoxItem(){ Content = "320:240" }, // QVGA
|
new(){ value = "-2:480", DisplayName = "480p keep AR (-2:480)" },
|
||||||
new ComboBoxItem(){ Content = "320:180" }, // 180p
|
new(){ value = "800:600", DisplayName = "SVGA exact (800:600)" },
|
||||||
new ComboBoxItem(){ Content = "256:144" }, // 144p
|
new(){ value = "768:432", DisplayName = "432p exact (768:432)" },
|
||||||
|
new(){ value = "-2:432", DisplayName = "432p keep AR (-2:432)" },
|
||||||
|
new(){ value = "720:480", DisplayName = "NTSC exact (720:480)" },
|
||||||
|
new(){ value = "704:576", DisplayName = "PAL exact (704:576)" },
|
||||||
|
new(){ value = "640:360", DisplayName = "360p exact (640:360)" },
|
||||||
|
new(){ value = "-2:360", DisplayName = "360p keep AR (-2:360)" },
|
||||||
|
new(){ value = "426:240", DisplayName = "240p exact (426:240)" },
|
||||||
|
new(){ value = "-2:240", DisplayName = "240p keep AR (-2:240)" },
|
||||||
|
new(){ value = "320:240", DisplayName = "QVGA exact (320:240)" },
|
||||||
|
new(){ value = "320:180", DisplayName = "180p exact (320:180)" },
|
||||||
|
new(){ value = "-2:180", DisplayName = "180p keep AR (-2:180)" },
|
||||||
|
new(){ value = "256:144", DisplayName = "144p exact (256:144)" },
|
||||||
|
new(){ value = "-2:144", DisplayName = "144p keep AR (-2:144)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){
|
public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){
|
||||||
|
|
@ -84,6 +109,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
|
|
||||||
AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" });
|
AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" });
|
||||||
|
AdditionalParameters.CollectionChanged += AdditionalParametersOnCollectionChanged;
|
||||||
|
|
||||||
if (editMode){
|
if (editMode){
|
||||||
EditMode = true;
|
EditMode = true;
|
||||||
|
|
@ -108,9 +134,9 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
PresetName = value.PresetName ?? "";
|
PresetName = value.PresetName ?? "";
|
||||||
Codec = value.Codec ?? "";
|
Codec = value.Codec ?? "";
|
||||||
Crf = value.Crf;
|
Crf = value.Crf;
|
||||||
FrameRate = value.FrameRate ?? "24";
|
FrameRate = value.FrameRate ?? "24000/1001";
|
||||||
|
|
||||||
SelectedResolution = ResolutionList.FirstOrDefault(e => e.Content?.ToString() == value.Resolution) ?? ResolutionList.First();
|
SelectedResolution = ResolutionList.FirstOrDefault(e => e.value == value.Resolution) ?? ResolutionList.First();
|
||||||
AdditionalParameters.Clear();
|
AdditionalParameters.Clear();
|
||||||
|
|
||||||
foreach (var valueAdditionalParameter in value.AdditionalParameters){
|
foreach (var valueAdditionalParameter in value.AdditionalParameters){
|
||||||
|
|
@ -133,13 +159,36 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
public void AddAdditionalParam(){
|
public void AddAdditionalParam(){
|
||||||
AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString });
|
AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString });
|
||||||
AdditionalParametersString = "";
|
AdditionalParametersString = "";
|
||||||
RaisePropertyChanged(nameof(AdditionalParametersString));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void RemoveAdditionalParam(StringItem param){
|
public void RemoveAdditionalParam(StringItem param){
|
||||||
AdditionalParameters.Remove(param);
|
AdditionalParameters.Remove(param);
|
||||||
RaisePropertyChanged(nameof(AdditionalParameters));
|
}
|
||||||
|
|
||||||
|
private void AdditionalParametersOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e){
|
||||||
|
OnPropertyChanged(nameof(CommandPreview));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildCommandPreview(){
|
||||||
|
var previewPreset = new VideoPreset{
|
||||||
|
PresetName = PresetName,
|
||||||
|
Codec = Codec,
|
||||||
|
FrameRate = string.IsNullOrWhiteSpace(FrameRate) ? "24000/1001" : FrameRate,
|
||||||
|
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
|
||||||
|
Resolution = SelectedResolution.value,
|
||||||
|
AdditionalParameters = AdditionalParameters
|
||||||
|
.Select(additionalParameter => additionalParameter.stringValue)
|
||||||
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var args = Helpers.BuildFFmpegArgsForPreset(
|
||||||
|
"S01E01.mkv",
|
||||||
|
previewPreset,
|
||||||
|
"S01E01_output.mkv");
|
||||||
|
|
||||||
|
return Helpers.BuildCommandString("ffmpeg", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
||||||
|
|
@ -153,7 +202,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
SelectedCustomPreset.Codec = Codec;
|
SelectedCustomPreset.Codec = Codec;
|
||||||
SelectedCustomPreset.FrameRate = FrameRate;
|
SelectedCustomPreset.FrameRate = FrameRate;
|
||||||
SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51);
|
SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51);
|
||||||
SelectedCustomPreset.Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080";
|
SelectedCustomPreset.Resolution = SelectedResolution.value;
|
||||||
SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList();
|
SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList();
|
||||||
|
|
||||||
try{
|
try{
|
||||||
|
|
@ -175,7 +224,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
Codec = Codec,
|
Codec = Codec,
|
||||||
FrameRate = FrameRate,
|
FrameRate = FrameRate,
|
||||||
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
|
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
|
||||||
Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080",
|
Resolution = SelectedResolution.value,
|
||||||
AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList()
|
AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -186,6 +235,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
|
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
|
||||||
|
AdditionalParameters.CollectionChanged -= AdditionalParametersOnCollectionChanged;
|
||||||
dialog.Closed -= DialogOnClosed;
|
dialog.Closed -= DialogOnClosed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -20,15 +20,20 @@ using CRD.Downloader.Crunchyroll;
|
||||||
using CRD.Utils;
|
using CRD.Utils;
|
||||||
using CRD.Utils.Files;
|
using CRD.Utils.Files;
|
||||||
using CRD.Utils.Http;
|
using CRD.Utils.Http;
|
||||||
|
using CRD.Utils.Notifications;
|
||||||
using CRD.Utils.Sonarr;
|
using CRD.Utils.Sonarr;
|
||||||
using CRD.Utils.Structs;
|
using CRD.Utils.Structs;
|
||||||
using CRD.Utils.Structs.Crunchyroll;
|
using CRD.Utils.Structs.Crunchyroll;
|
||||||
using CRD.Utils.Structs.History;
|
using CRD.Utils.Structs.History;
|
||||||
|
using CRD.Views;
|
||||||
using FluentAvalonia.Styling;
|
using FluentAvalonia.Styling;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
namespace CRD.ViewModels.Utils;
|
namespace CRD.ViewModels.Utils;
|
||||||
|
|
||||||
public partial class GeneralSettingsViewModel : ViewModelBase{
|
public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
|
private readonly AudioPlayer notificationTestPlayer = new();
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string currentVersion;
|
private string currentVersion;
|
||||||
|
|
||||||
|
|
@ -44,6 +49,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool historyIncludeCrArtists;
|
private bool historyIncludeCrArtists;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool historyRemoveMissingEpisodes;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool historyAddSpecials;
|
private bool historyAddSpecials;
|
||||||
|
|
||||||
|
|
@ -59,6 +67,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HistoryRefreshMode historyAutoRefreshMode;
|
private HistoryRefreshMode historyAutoRefreshMode;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool historyAutoRefreshAddToQueue;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string historyAutoRefreshModeHint;
|
private string historyAutoRefreshModeHint;
|
||||||
|
|
||||||
|
|
@ -86,6 +97,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool downloadAllowEarlyStart;
|
private bool downloadAllowEarlyStart;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool persistQueue;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double? downloadSpeed;
|
private double? downloadSpeed;
|
||||||
|
|
||||||
|
|
@ -98,6 +112,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private double? retryDelay;
|
private double? retryDelay;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double? playbackRateLimitRetryDelaySeconds;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private double? retryMaxDelaySeconds;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool trayIconEnabled;
|
private bool trayIconEnabled;
|
||||||
|
|
||||||
|
|
@ -285,9 +305,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string downloadFinishedExecutePath;
|
private string downloadFinishedExecutePath;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookEnabled;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string webhookUrl = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string webhookMethod = "POST";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string webhookContentType = "application/json";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string webhookHeadersText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string webhookBodyTemplate = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyQueueFinished;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyDownloadFinished;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyDownloadFailed;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyTrackedSeriesEpisodeReleased;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyLoginExpired;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool webhookNotifyUpdateAvailable;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string currentIp = "";
|
private string currentIp = "";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isTestingFinishedSound;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isTestingWebhook;
|
||||||
|
|
||||||
private readonly FluentAvaloniaTheme faTheme;
|
private readonly FluentAvaloniaTheme faTheme;
|
||||||
|
|
||||||
private bool settingsLoaded;
|
private bool settingsLoaded;
|
||||||
|
|
@ -312,16 +374,29 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
|
|
||||||
CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions;
|
CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
options.NormalizeNotificationSettings();
|
||||||
|
|
||||||
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
|
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
|
||||||
BackgroundImageOpacity = options.BackgroundImageOpacity;
|
BackgroundImageOpacity = options.BackgroundImageOpacity;
|
||||||
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
|
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
|
||||||
|
|
||||||
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
|
var soundProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||||
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
|
DownloadFinishedSoundPath = soundProvider?.Path ?? string.Empty;
|
||||||
|
DownloadFinishedPlaySound = soundProvider?.Enabled ?? false;
|
||||||
|
|
||||||
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
|
var executeProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||||
DownloadFinishedExecute = options.DownloadFinishedExecute;
|
DownloadFinishedExecutePath = executeProvider?.Path ?? string.Empty;
|
||||||
|
DownloadFinishedExecute = executeProvider?.Enabled ?? false;
|
||||||
|
|
||||||
|
var webhookProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Webhook);
|
||||||
|
WebhookEnabled = webhookProvider?.Enabled ?? false;
|
||||||
|
WebhookUrl = webhookProvider?.Url ?? string.Empty;
|
||||||
|
WebhookMethod = string.IsNullOrWhiteSpace(webhookProvider?.Method) ? "POST" : webhookProvider.Method;
|
||||||
|
WebhookContentType = string.IsNullOrWhiteSpace(webhookProvider?.ContentType) ? "application/json" : webhookProvider.ContentType;
|
||||||
|
WebhookHeadersText = SerializeHeaders(webhookProvider?.Headers);
|
||||||
|
WebhookBodyTemplate = webhookProvider?.BodyTemplate ?? string.Empty;
|
||||||
|
|
||||||
|
LoadProviderEvents(webhookProvider, NotificationProviderType.Webhook);
|
||||||
|
|
||||||
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
|
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
|
||||||
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
|
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
|
||||||
|
|
@ -365,19 +440,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
ProxyPort = options.ProxyPort;
|
ProxyPort = options.ProxyPort;
|
||||||
HistoryCountMissing = options.HistoryCountMissing;
|
HistoryCountMissing = options.HistoryCountMissing;
|
||||||
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
|
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
|
||||||
|
HistoryRemoveMissingEpisodes = options.HistoryRemoveMissingEpisodes;
|
||||||
HistoryAddSpecials = options.HistoryAddSpecials;
|
HistoryAddSpecials = options.HistoryAddSpecials;
|
||||||
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
|
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
|
||||||
HistoryCountSonarr = options.HistoryCountSonarr;
|
HistoryCountSonarr = options.HistoryCountSonarr;
|
||||||
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
|
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
|
||||||
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
|
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
|
||||||
|
HistoryAutoRefreshAddToQueue = options.HistoryAutoRefreshAddToQueue;
|
||||||
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
|
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
|
||||||
DownloadSpeed = options.DownloadSpeedLimit;
|
DownloadSpeed = options.DownloadSpeedLimit;
|
||||||
DownloadSpeedInBits = options.DownloadSpeedInBits;
|
DownloadSpeedInBits = options.DownloadSpeedInBits;
|
||||||
DownloadMethodeNew = options.DownloadMethodeNew;
|
DownloadMethodeNew = options.DownloadMethodeNew;
|
||||||
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
|
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
|
||||||
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
|
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
|
||||||
|
PersistQueue = options.PersistQueue;
|
||||||
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
|
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
|
||||||
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
|
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
|
||||||
|
PlaybackRateLimitRetryDelaySeconds = Math.Clamp(options.PlaybackRateLimitRetryDelaySeconds, 1, 86400);
|
||||||
|
RetryMaxDelaySeconds = Math.Clamp(options.RetryMaxDelaySeconds, 1, 86400);
|
||||||
DownloadToTempFolder = options.DownloadToTempFolder;
|
DownloadToTempFolder = options.DownloadToTempFolder;
|
||||||
SimultaneousDownloads = options.SimultaneousDownloads;
|
SimultaneousDownloads = options.SimultaneousDownloads;
|
||||||
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
|
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
|
||||||
|
|
@ -417,34 +497,65 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
|
|
||||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
|
||||||
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
|
settings.NotificationSettings ??= new NotificationSettings();
|
||||||
|
|
||||||
settings.DownloadFinishedExecute = DownloadFinishedExecute;
|
var soundProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||||
|
soundProvider.Enabled = DownloadFinishedPlaySound;
|
||||||
|
soundProvider.Path = DownloadFinishedSoundPath;
|
||||||
|
soundProvider.Events = [NotificationEventType.QueueFinished];
|
||||||
|
|
||||||
|
var executeProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||||
|
executeProvider.Enabled = DownloadFinishedExecute;
|
||||||
|
executeProvider.Path = DownloadFinishedExecutePath;
|
||||||
|
executeProvider.Events = [NotificationEventType.QueueFinished];
|
||||||
|
|
||||||
|
var webhookProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Webhook);
|
||||||
|
webhookProvider.Enabled = WebhookEnabled;
|
||||||
|
webhookProvider.Url = WebhookUrl?.Trim() ?? string.Empty;
|
||||||
|
webhookProvider.Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant();
|
||||||
|
webhookProvider.ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim();
|
||||||
|
webhookProvider.Headers = ParseHeaders(WebhookHeadersText);
|
||||||
|
webhookProvider.BodyTemplate = WebhookBodyTemplate ?? string.Empty;
|
||||||
|
webhookProvider.Events = BuildEvents(
|
||||||
|
WebhookNotifyQueueFinished,
|
||||||
|
WebhookNotifyDownloadFinished,
|
||||||
|
WebhookNotifyDownloadFailed,
|
||||||
|
WebhookNotifyTrackedSeriesEpisodeReleased,
|
||||||
|
WebhookNotifyLoginExpired,
|
||||||
|
WebhookNotifyUpdateAvailable
|
||||||
|
);
|
||||||
|
|
||||||
|
settings.SyncLegacyNotificationFields();
|
||||||
|
|
||||||
settings.DownloadMethodeNew = DownloadMethodeNew;
|
settings.DownloadMethodeNew = DownloadMethodeNew;
|
||||||
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
|
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
|
||||||
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
|
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
|
||||||
|
settings.PersistQueue = PersistQueue;
|
||||||
|
|
||||||
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
|
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
|
||||||
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
|
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
|
||||||
|
|
||||||
settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10);
|
settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10);
|
||||||
settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30);
|
settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30);
|
||||||
|
settings.PlaybackRateLimitRetryDelaySeconds = Math.Clamp((int)(PlaybackRateLimitRetryDelaySeconds ?? 0), 1, 86400);
|
||||||
|
settings.RetryMaxDelaySeconds = Math.Clamp((int)(RetryMaxDelaySeconds ?? 0), 1, 86400);
|
||||||
|
|
||||||
settings.DownloadToTempFolder = DownloadToTempFolder;
|
settings.DownloadToTempFolder = DownloadToTempFolder;
|
||||||
settings.HistoryCountMissing = HistoryCountMissing;
|
settings.HistoryCountMissing = HistoryCountMissing;
|
||||||
settings.HistoryAddSpecials = HistoryAddSpecials;
|
settings.HistoryAddSpecials = HistoryAddSpecials;
|
||||||
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
|
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
|
||||||
|
settings.HistoryRemoveMissingEpisodes = HistoryRemoveMissingEpisodes;
|
||||||
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
|
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
|
||||||
settings.HistoryCountSonarr = HistoryCountSonarr;
|
settings.HistoryCountSonarr = HistoryCountSonarr;
|
||||||
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
|
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
|
||||||
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
|
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
|
||||||
|
settings.HistoryAutoRefreshAddToQueue = HistoryAutoRefreshAddToQueue;
|
||||||
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
|
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
|
||||||
settings.DownloadSpeedInBits = DownloadSpeedInBits;
|
settings.DownloadSpeedInBits = DownloadSpeedInBits;
|
||||||
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
|
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
|
||||||
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
|
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
|
||||||
|
|
||||||
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs);
|
QueueManager.Instance.SetProcessingLimit(settings.SimultaneousProcessingJobs);
|
||||||
|
|
||||||
settings.ProxyEnabled = ProxyEnabled;
|
settings.ProxyEnabled = ProxyEnabled;
|
||||||
settings.ProxySocks = ProxySocks;
|
settings.ProxySocks = ProxySocks;
|
||||||
|
|
@ -519,6 +630,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
settings.LogMode = LogMode;
|
settings.LogMode = LogMode;
|
||||||
|
|
||||||
CfgManager.WriteCrSettings();
|
CfgManager.WriteCrSettings();
|
||||||
|
|
||||||
|
if (!PersistQueue){
|
||||||
|
QueueManager.Instance.SaveQueueSnapshot();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|
@ -613,7 +728,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ClearFinishedSoundPath(){
|
public void ClearFinishedSoundPath(){
|
||||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = string.Empty;
|
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
settings.NotificationSettings ??= new NotificationSettings();
|
||||||
|
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = string.Empty;
|
||||||
|
settings.SyncLegacyNotificationFields();
|
||||||
DownloadFinishedSoundPath = string.Empty;
|
DownloadFinishedSoundPath = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -627,21 +745,131 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pathSetter: (path) => {
|
pathSetter: (path) => {
|
||||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
|
var validationResult = AudioPlayer.ValidateSoundFile(path);
|
||||||
|
if (!validationResult.IsValid){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage(validationResult.ErrorMessage, ToastType.Error, 5));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
settings.NotificationSettings ??= new NotificationSettings();
|
||||||
|
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = path;
|
||||||
|
settings.SyncLegacyNotificationFields();
|
||||||
DownloadFinishedSoundPath = path;
|
DownloadFinishedSoundPath = path;
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Notification sound updated", ToastType.Information, 2));
|
||||||
},
|
},
|
||||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
|
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty,
|
||||||
defaultPath: string.Empty
|
defaultPath: string.Empty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task TestFinishedSoundAsync(){
|
||||||
|
if (IsTestingFinishedSound){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty;
|
||||||
|
IsTestingFinishedSound = true;
|
||||||
|
|
||||||
|
try{
|
||||||
|
var result = await notificationTestPlayer.ValidatePlaybackAsync(path);
|
||||||
|
|
||||||
|
if (result.IsSuccess){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Notification sound test succeeded", ToastType.Information, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage($"Notification sound test failed: {result.ErrorMessage}", ToastType.Error, 5));
|
||||||
|
} finally{
|
||||||
|
IsTestingFinishedSound = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task StopFinishedSoundAsync(){
|
||||||
|
await notificationTestPlayer.StopAsync();
|
||||||
|
IsTestingFinishedSound = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task TestWebhookAsync(){
|
||||||
|
if (IsTestingWebhook){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedEvents = BuildEvents(
|
||||||
|
WebhookNotifyQueueFinished,
|
||||||
|
WebhookNotifyDownloadFinished,
|
||||||
|
WebhookNotifyDownloadFailed,
|
||||||
|
WebhookNotifyTrackedSeriesEpisodeReleased,
|
||||||
|
WebhookNotifyLoginExpired,
|
||||||
|
WebhookNotifyUpdateAvailable
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!WebhookEnabled){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Enable the webhook first", ToastType.Error, 4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(WebhookUrl)){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Set a webhook URL first", ToastType.Error, 4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEvents.Count == 0){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Select at least one webhook event to test", ToastType.Error, 4));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsTestingWebhook = true;
|
||||||
|
|
||||||
|
try{
|
||||||
|
var settings = new NotificationSettings{
|
||||||
|
Providers = [
|
||||||
|
new NotificationProviderConfig{
|
||||||
|
Type = NotificationProviderType.Webhook,
|
||||||
|
Enabled = true,
|
||||||
|
Url = WebhookUrl.Trim(),
|
||||||
|
Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant(),
|
||||||
|
ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim(),
|
||||||
|
Headers = ParseHeaders(WebhookHeadersText),
|
||||||
|
BodyTemplate = WebhookBodyTemplate ?? string.Empty,
|
||||||
|
Events = selectedEvents
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var sentCount = 0;
|
||||||
|
|
||||||
|
foreach (var notificationEvent in BuildTestWebhookEvents(selectedEvents)){
|
||||||
|
if (await NotificationDispatcher.Instance.PublishWithResultAsync(settings, notificationEvent)){
|
||||||
|
sentCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentCount == selectedEvents.Count){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} test webhook event(s)", ToastType.Information, 3));
|
||||||
|
} else if (sentCount > 0){
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} of {selectedEvents.Count} test webhook event(s)", ToastType.Error, 5));
|
||||||
|
} else{
|
||||||
|
MessageBus.Current.SendMessage(new ToastMessage("Webhook test failed for all selected events", ToastType.Error, 5));
|
||||||
|
}
|
||||||
|
} finally{
|
||||||
|
IsTestingWebhook = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Download Finished Execute File
|
#region Download Finished Execute File
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ClearFinishedExectuePath(){
|
public void ClearFinishedExectuePath(){
|
||||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
|
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
settings.NotificationSettings ??= new NotificationSettings();
|
||||||
|
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = string.Empty;
|
||||||
|
settings.SyncLegacyNotificationFields();
|
||||||
DownloadFinishedExecutePath = string.Empty;
|
DownloadFinishedExecutePath = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,10 +883,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pathSetter: (path) => {
|
pathSetter: (path) => {
|
||||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
|
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||||
|
settings.NotificationSettings ??= new NotificationSettings();
|
||||||
|
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = path;
|
||||||
|
settings.SyncLegacyNotificationFields();
|
||||||
DownloadFinishedExecutePath = path;
|
DownloadFinishedExecutePath = path;
|
||||||
},
|
},
|
||||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
|
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute).Path ?? string.Empty,
|
||||||
defaultPath: string.Empty
|
defaultPath: string.Empty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -692,6 +923,189 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadProviderEvents(NotificationProviderConfig? provider, NotificationProviderType type){
|
||||||
|
var events = provider?.Events ?? [];
|
||||||
|
|
||||||
|
switch (type){
|
||||||
|
case NotificationProviderType.Webhook:
|
||||||
|
WebhookNotifyQueueFinished = events.Contains(NotificationEventType.QueueFinished);
|
||||||
|
WebhookNotifyDownloadFinished = events.Contains(NotificationEventType.DownloadFinished);
|
||||||
|
WebhookNotifyDownloadFailed = events.Contains(NotificationEventType.DownloadFailed);
|
||||||
|
WebhookNotifyTrackedSeriesEpisodeReleased = events.Contains(NotificationEventType.TrackedSeriesEpisodeReleased);
|
||||||
|
WebhookNotifyLoginExpired = events.Contains(NotificationEventType.LoginExpired);
|
||||||
|
WebhookNotifyUpdateAvailable = events.Contains(NotificationEventType.UpdateAvailable);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<NotificationEventType> BuildEvents(
|
||||||
|
bool queueFinished,
|
||||||
|
bool downloadFinished,
|
||||||
|
bool downloadFailed,
|
||||||
|
bool trackedSeriesEpisodeReleased,
|
||||||
|
bool loginExpired,
|
||||||
|
bool updateAvailable){
|
||||||
|
var events = new List<NotificationEventType>();
|
||||||
|
|
||||||
|
if (queueFinished){
|
||||||
|
events.Add(NotificationEventType.QueueFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadFinished){
|
||||||
|
events.Add(NotificationEventType.DownloadFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadFailed){
|
||||||
|
events.Add(NotificationEventType.DownloadFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackedSeriesEpisodeReleased){
|
||||||
|
events.Add(NotificationEventType.TrackedSeriesEpisodeReleased);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginExpired){
|
||||||
|
events.Add(NotificationEventType.LoginExpired);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateAvailable){
|
||||||
|
events.Add(NotificationEventType.UpdateAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<NotificationEvent> BuildTestWebhookEvents(IEnumerable<NotificationEventType> selectedEvents){
|
||||||
|
foreach (var eventType in selectedEvents.Distinct()){
|
||||||
|
yield return BuildTestWebhookEvent(eventType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationEvent BuildTestWebhookEvent(NotificationEventType eventType){
|
||||||
|
return eventType switch{
|
||||||
|
NotificationEventType.QueueFinished => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.QueueFinished,
|
||||||
|
Title = "Downloads finished",
|
||||||
|
Message = "All queued downloads have finished processing.",
|
||||||
|
Metadata = []
|
||||||
|
},
|
||||||
|
NotificationEventType.DownloadFinished => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.DownloadFinished,
|
||||||
|
Title = "Download finished",
|
||||||
|
Message = "Finished processing Example Series.",
|
||||||
|
Metadata = BuildTestDownloadMetadata()
|
||||||
|
},
|
||||||
|
NotificationEventType.DownloadFailed => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.DownloadFailed,
|
||||||
|
Title = "Download failed",
|
||||||
|
Message = "Failed to download Example Series: Example failure message",
|
||||||
|
Metadata = BuildTestDownloadMetadata("Example failure message")
|
||||||
|
},
|
||||||
|
NotificationEventType.TrackedSeriesEpisodeReleased => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.TrackedSeriesEpisodeReleased,
|
||||||
|
Title = "Tracked series episode released",
|
||||||
|
Message = "A tracked episode is available for Example Series: Episode Title.",
|
||||||
|
Metadata = new Dictionary<string, string>{
|
||||||
|
["seriesTitle"] = "Example Series",
|
||||||
|
["seriesId"] = "G6ABC1234",
|
||||||
|
["seasonId"] = "G6SEASON01",
|
||||||
|
["episodeTitle"] = "Episode Title",
|
||||||
|
["episodeId"] = "G6EP0001",
|
||||||
|
["episodeNumber"] = "1",
|
||||||
|
["seasonNumber"] = "1",
|
||||||
|
["releaseDate"] = DateTimeOffset.UtcNow.AddMinutes(-30).ToString("O"),
|
||||||
|
["premiumAvailableDate"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||||
|
["episodeUrl"] = "https://www.crunchyroll.com/en-US/watch/G6EP0001/episode-title",
|
||||||
|
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
|
||||||
|
["description"] = "Example tracked-release description.",
|
||||||
|
["durationMs"] = "1440000",
|
||||||
|
["availableDubs"] = "en-US, ja-JP",
|
||||||
|
["availableSubs"] = "en-US, de-DE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NotificationEventType.LoginExpired => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.LoginExpired,
|
||||||
|
Title = "Crunchyroll login expired",
|
||||||
|
Message = "The saved Crunchyroll session could not be refreshed. Please log in again.",
|
||||||
|
Metadata = new Dictionary<string, string>{
|
||||||
|
["username"] = "example-user",
|
||||||
|
["endpoint"] = "/auth/v1/token"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NotificationEventType.UpdateAvailable => new NotificationEvent{
|
||||||
|
Type = NotificationEventType.UpdateAvailable,
|
||||||
|
Title = "Update available",
|
||||||
|
Message = "Version v9.9.9 is available. Current version: v1.0.0.",
|
||||||
|
Metadata = new Dictionary<string, string>{
|
||||||
|
["currentVersion"] = "v1.0.0",
|
||||||
|
["latestVersion"] = "v9.9.9",
|
||||||
|
["platform"] = "win-x64",
|
||||||
|
["downloadUrl"] = "https://github.com/Crunchy-DL/Crunchy-Downloader/releases/latest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildTestDownloadMetadata(string? error = null){
|
||||||
|
var metadata = new Dictionary<string, string>{
|
||||||
|
["seriesTitle"] = "Example Series",
|
||||||
|
["seasonTitle"] = "Season 1",
|
||||||
|
["episodeTitle"] = "Episode Title",
|
||||||
|
["episodeNumber"] = "1",
|
||||||
|
["episodeId"] = "G6EP0001",
|
||||||
|
["downloadPath"] = @"C:\Downloads\Example Series\Season 1",
|
||||||
|
["seasonNumber"] = "1",
|
||||||
|
["description"] = "Example download description.",
|
||||||
|
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
|
||||||
|
["imageUrlLarge"] = "https://static.crunchyroll.com/example-poster.jpg",
|
||||||
|
["downloadSubs"] = "en-US, de-DE",
|
||||||
|
["downloadDubs"] = "ja-JP",
|
||||||
|
["hardsub"] = string.Empty,
|
||||||
|
["seriesId"] = "G6ABC1234",
|
||||||
|
["seasonId"] = "G6SEASON01",
|
||||||
|
["episodeUrl"] = "https://www.crunchyroll.com/watch/G6EP0001"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(error)){
|
||||||
|
metadata["error"] = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeHeaders(IReadOnlyDictionary<string, string>? headers){
|
||||||
|
if (headers == null || headers.Count == 0){
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(Environment.NewLine, headers.Select(pair => $"{pair.Key}: {pair.Value}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseHeaders(string? headerText){
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (string.IsNullOrWhiteSpace(headerText)){
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rawLine in headerText.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)){
|
||||||
|
var separatorIndex = rawLine.IndexOf(':');
|
||||||
|
if (separatorIndex <= 0){
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = rawLine[..separatorIndex].Trim();
|
||||||
|
var value = rawLine[(separatorIndex + 1)..].Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(key)){
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
|
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
|
||||||
if (value?.Content?.ToString() == "System"){
|
if (value?.Content?.ToString() == "System"){
|
||||||
|
|
@ -758,7 +1172,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
or nameof(CurrentAppTheme)
|
or nameof(CurrentAppTheme)
|
||||||
or nameof(UseCustomAccent)
|
or nameof(UseCustomAccent)
|
||||||
or nameof(TrayIconEnabled)
|
or nameof(TrayIconEnabled)
|
||||||
or nameof(LogMode)){
|
or nameof(LogMode)
|
||||||
|
or nameof(PersistQueue)){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,4 +1244,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||||
CfgManager.DisableLogMode();
|
CfgManager.DisableLogMode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnPersistQueueChanged(bool value){
|
||||||
|
UpdateSettings();
|
||||||
|
QueueManager.Instance.SaveQueueSnapshot();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +43,20 @@
|
||||||
</ToolTip.Tip>
|
</ToolTip.Tip>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button BorderThickness="0"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0 0 10 0 "
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsEnabled="{Binding QueueManagerIns.HasActiveDownloads}"
|
||||||
|
Command="{Binding PauseQueue}">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<controls:SymbolIcon Symbol="Pause" FontSize="22" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToolTip.Tip>
|
||||||
|
<TextBlock Text="Pause running" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
|
||||||
|
</ToolTip.Tip>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button BorderThickness="0"
|
<Button BorderThickness="0"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
Margin="0 0 10 0 "
|
Margin="0 0 10 0 "
|
||||||
|
|
@ -108,11 +122,11 @@
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<controls:SymbolIcon Symbol="{Binding
|
<controls:SymbolIcon Symbol="{Binding
|
||||||
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" />
|
ShowPauseIcon, Converter={StaticResource UiValueConverter}}" FontSize="18" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic"
|
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding RetryDownload}" FontStyle="Italic"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,16 @@
|
||||||
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" />
|
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="8 0 5 0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Text="Movies" Grid.Column="0" VerticalAlignment="Center" />
|
||||||
|
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowMovies}" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
<Rectangle Height="1" Fill="Gray" Margin="0,8,0,8" />
|
<Rectangle Height="1" Fill="Gray" Margin="0,8,0,8" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,29 +38,43 @@
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="Enter Codec" Margin="0,10,0,5" />
|
<TextBlock Text="Enter Codec" Margin="0,10,0,5" />
|
||||||
<TextBox Watermark="libx265" Text="{Binding Codec}" />
|
<TextBox Watermark="libx265" Text="{Binding Codec}" />
|
||||||
|
<TextBlock Text="Leave empty to provide the encoding options through Additional Parameters only."
|
||||||
|
Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !HasCodec}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Resolution ComboBox -->
|
<!-- Resolution ComboBox -->
|
||||||
<StackPanel>
|
<StackPanel IsVisible="{Binding HasCodec}">
|
||||||
<TextBlock Text="Select Resolution" Margin="0,10,0,5" />
|
<TextBlock Text="Select Resolution" Margin="0,10,0,5" />
|
||||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||||
ItemsSource="{Binding ResolutionList}"
|
ItemsSource="{Binding ResolutionList}"
|
||||||
SelectedItem="{Binding SelectedResolution}">
|
SelectedItem="{Binding SelectedResolution}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding DisplayName}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<TextBlock Text="Use exact sizes for fixed output dimensions, or keep AR to preserve the source aspect ratio."
|
||||||
|
Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Frame Rate NumberBox -->
|
<!-- Frame Rate NumberBox -->
|
||||||
<StackPanel>
|
<StackPanel IsVisible="{Binding HasCodec}">
|
||||||
<TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" />
|
<TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" />
|
||||||
<!-- <controls:NumberBox Minimum="1" Maximum="999" -->
|
<!-- <controls:NumberBox Minimum="1" Maximum="999" -->
|
||||||
<!-- Value="{Binding FrameRate}" -->
|
<!-- Value="{Binding FrameRate}" -->
|
||||||
<!-- SpinButtonPlacementMode="Inline" -->
|
<!-- SpinButtonPlacementMode="Inline" -->
|
||||||
<!-- HorizontalAlignment="Stretch" /> -->
|
<!-- HorizontalAlignment="Stretch" /> -->
|
||||||
<TextBox Watermark="24" Text="{Binding FrameRate}" />
|
<TextBox Watermark="24000/1001" Text="{Binding FrameRate}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- CRF NumberBox -->
|
<!-- CRF NumberBox -->
|
||||||
<StackPanel>
|
<StackPanel IsVisible="{Binding HasCodec}">
|
||||||
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
|
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
|
||||||
<controls:NumberBox Minimum="0" Maximum="51"
|
<controls:NumberBox Minimum="0" Maximum="51"
|
||||||
Value="{Binding Crf}"
|
Value="{Binding Crf}"
|
||||||
|
|
@ -109,6 +123,25 @@
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,20,0,0">
|
||||||
|
<TextBlock Text="Generated FFmpeg Command" Margin="0,0,0,5" />
|
||||||
|
<Border BorderBrush="#4a4a4a"
|
||||||
|
Background="{DynamicResource ControlAltFillColorQuarternary}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10"
|
||||||
|
MaxWidth="700"
|
||||||
|
HorizontalAlignment="Left">
|
||||||
|
<SelectableTextBlock Text="{Binding CommandPreview}"
|
||||||
|
FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="This preview uses sample input and output file names, but the generated options match the preset."
|
||||||
|
Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@
|
||||||
</controls:SettingsExpanderItem.Footer>
|
</controls:SettingsExpanderItem.Footer>
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
|
<controls:SettingsExpanderItem Content="Remove Missing History Episodes" Description="During history refresh, remove episodes no longer available on the streaming service. Seasons with no episodes left are removed too.">
|
||||||
|
<controls:SettingsExpanderItem.Footer>
|
||||||
|
<CheckBox IsChecked="{Binding HistoryRemoveMissingEpisodes}"> </CheckBox>
|
||||||
|
</controls:SettingsExpanderItem.Footer>
|
||||||
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue/count if they weren't downloaded before">
|
<controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue/count if they weren't downloaded before">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
|
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
|
||||||
|
|
@ -91,6 +97,13 @@
|
||||||
Text="{Binding HistoryAutoRefreshModeHint}" />
|
Text="{Binding HistoryAutoRefreshModeHint}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<CheckBox IsChecked="{Binding HistoryAutoRefreshAddToQueue}"
|
||||||
|
Content="Add newly found missing episodes to the queue" />
|
||||||
|
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="When disabled, auto refresh only updates history and missing counts." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
|
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
@ -107,6 +120,12 @@
|
||||||
Description="Adjust download settings"
|
Description="Adjust download settings"
|
||||||
IsExpanded="False">
|
IsExpanded="False">
|
||||||
|
|
||||||
|
<controls:SettingsExpanderItem Content="Persist queue" Description="Save the current download queue on exit and restore it on the next start">
|
||||||
|
<controls:SettingsExpanderItem.Footer>
|
||||||
|
<CheckBox IsChecked="{Binding PersistQueue}"> </CheckBox>
|
||||||
|
</controls:SettingsExpanderItem.Footer>
|
||||||
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="Enable New Download Method" Description="Enables the updated download handling logic. This may improve performance and stability.">
|
<controls:SettingsExpanderItem Content="Enable New Download Method" Description="Enables the updated download handling logic. This may improve performance and stability.">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
|
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
|
||||||
|
|
@ -146,19 +165,33 @@
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Width="100" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
<TextBlock Width="200" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||||
<controls:NumberBox Minimum="1" Maximum="10"
|
<controls:NumberBox Minimum="1" Maximum="10"
|
||||||
Value="{Binding RetryAttempts}"
|
Value="{Binding RetryAttempts}"
|
||||||
SpinButtonPlacementMode="Hidden"
|
SpinButtonPlacementMode="Hidden"
|
||||||
HorizontalAlignment="Stretch" />
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Margin="0 5">
|
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||||
<TextBlock Width="100" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
<TextBlock Width="200" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||||
<controls:NumberBox Minimum="1" Maximum="30"
|
<controls:NumberBox Minimum="1" Maximum="30"
|
||||||
Value="{Binding RetryDelay}"
|
Value="{Binding RetryDelay}"
|
||||||
SpinButtonPlacementMode="Hidden"
|
SpinButtonPlacementMode="Hidden"
|
||||||
HorizontalAlignment="Stretch" />
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||||
|
<TextBlock Width="200" Text="Rate Limit Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||||
|
<controls:NumberBox Minimum="1" Maximum="86400"
|
||||||
|
Value="{Binding PlaybackRateLimitRetryDelaySeconds}"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||||
|
<TextBlock Width="200" Text="Rate Limit Max Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||||
|
<controls:NumberBox Minimum="1" Maximum="86400"
|
||||||
|
Value="{Binding RetryMaxDelaySeconds}"
|
||||||
|
SpinButtonPlacementMode="Hidden"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -246,68 +279,117 @@
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">
|
<controls:SettingsExpander.Footer>
|
||||||
|
</controls:SettingsExpander.Footer>
|
||||||
|
</controls:SettingsExpander>
|
||||||
|
|
||||||
|
<controls:SettingsExpander Header="Notifications"
|
||||||
|
IconSource="AlertOn"
|
||||||
|
Description="Configure sound, file execution, and webhook notifications"
|
||||||
|
IsExpanded="False">
|
||||||
|
|
||||||
|
<controls:SettingsExpanderItem Content="Sound notification" Description="Play a sound file when the queue finishes">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10" Width="520">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||||
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}"
|
<TextBlock Grid.Column="0"
|
||||||
|
IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||||
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
|
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
|
||||||
FontSize="15"
|
FontSize="15"
|
||||||
Opacity="0.8"
|
Opacity="0.8"
|
||||||
TextWrapping="NoWrap"
|
TextWrapping="NoWrap"
|
||||||
TextAlignment="Center"
|
TextTrimming="CharacterEllipsis"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center">
|
||||||
|
<ToolTip.Tip>
|
||||||
|
<TextBlock Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" FontSize="15" />
|
||||||
|
</ToolTip.Tip>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||||
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
|
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontStyle="Italic">
|
FontStyle="Italic">
|
||||||
<ToolTip.Tip>
|
<ToolTip.Tip>
|
||||||
<TextBlock Text="Select Finished Sound" FontSize="15" />
|
<TextBlock Text="Select notification sound" FontSize="15" />
|
||||||
</ToolTip.Tip>
|
</ToolTip.Tip>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<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}"
|
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||||
Command="{Binding ClearFinishedSoundPath}"
|
Command="{Binding ClearFinishedSoundPath}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontStyle="Italic">
|
FontStyle="Italic">
|
||||||
<ToolTip.Tip>
|
<ToolTip.Tip>
|
||||||
<TextBlock Text="Remove Finished Sound Path" FontSize="15" />
|
<TextBlock Text="Clear selected notification sound" FontSize="15" />
|
||||||
</ToolTip.Tip>
|
</ToolTip.Tip>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
|
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="This notification is sent only when the full queue finishes." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</controls:SettingsExpanderItem.Footer>
|
</controls:SettingsExpanderItem.Footer>
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
|
<controls:SettingsExpanderItem Content="Execute file" Description="Run a selected file when the queue finishes">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10" Width="520">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||||
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
|
<TextBlock Grid.Column="0"
|
||||||
|
IsVisible="{Binding DownloadFinishedExecute}"
|
||||||
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
|
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
|
||||||
FontSize="15"
|
FontSize="15"
|
||||||
Opacity="0.8"
|
Opacity="0.8"
|
||||||
TextWrapping="NoWrap"
|
TextWrapping="NoWrap"
|
||||||
TextAlignment="Center"
|
TextTrimming="CharacterEllipsis"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center">
|
||||||
|
<ToolTip.Tip>
|
||||||
|
<TextBlock Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" FontSize="15" />
|
||||||
|
</ToolTip.Tip>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
||||||
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
|
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontStyle="Italic">
|
FontStyle="Italic">
|
||||||
<ToolTip.Tip>
|
<ToolTip.Tip>
|
||||||
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
|
<TextBlock Text="Select file to execute" FontSize="15" />
|
||||||
</ToolTip.Tip>
|
</ToolTip.Tip>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||||
|
|
@ -326,16 +408,83 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
|
<CheckBox IsChecked="{Binding DownloadFinishedExecute}" />
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
</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.Footer>
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
|
<controls:SettingsExpanderItem Content="Webhook" Description="Send an HTTP request when selected notification events are raised">
|
||||||
|
<controls:SettingsExpanderItem.Footer>
|
||||||
|
<StackPanel Spacing="10" Width="520">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
|
<TextBox Watermark="https://example.com/webhook"
|
||||||
|
MinWidth="420"
|
||||||
|
Text="{Binding WebhookUrl}" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookEnabled}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<controls:SettingsExpander.Footer>
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
</controls:SettingsExpander.Footer>
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Method" />
|
||||||
|
<TextBox MinWidth="120" Text="{Binding WebhookMethod}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Content type" />
|
||||||
|
<TextBox MinWidth="220" Text="{Binding WebhookContentType}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Headers" />
|
||||||
|
<TextBox AcceptsReturn="True"
|
||||||
|
Height="90"
|
||||||
|
Text="{Binding WebhookHeadersText}"
|
||||||
|
Watermark="Authorization: Bearer token X-App: CRD" />
|
||||||
|
<TextBlock Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Enter one header per line in the format Name: Value." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Body template" />
|
||||||
|
<TextBox AcceptsReturn="True"
|
||||||
|
Height="120"
|
||||||
|
Text="{Binding WebhookBodyTemplate}"
|
||||||
|
Watermark="JSON template using placeholders like {{eventType}} and {{message}}" />
|
||||||
|
<TextBlock Opacity="0.7"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="Leave empty to send the default JSON payload. Available placeholders include {{eventType}}, {{title}}, {{message}}, {{timestampUtc}}, and metadata keys." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<WrapPanel ItemWidth="245" Orientation="Horizontal">
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyQueueFinished}" Content="Queue finished" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFinished}" Content="Download finished" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFailed}" Content="Download failed" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyTrackedSeriesEpisodeReleased}" Content="Tracked series episode released" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyLoginExpired}" Content="Login expired" />
|
||||||
|
<CheckBox IsChecked="{Binding WebhookNotifyUpdateAvailable}" Content="Update available" />
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||||
|
<Button Command="{Binding TestWebhookCommand}"
|
||||||
|
Content="Send selected test events" />
|
||||||
|
<ProgressBar Width="120"
|
||||||
|
IsIndeterminate="True"
|
||||||
|
IsVisible="{Binding IsTestingWebhook}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</controls:SettingsExpanderItem.Footer>
|
||||||
|
</controls:SettingsExpanderItem>
|
||||||
</controls:SettingsExpander>
|
</controls:SettingsExpander>
|
||||||
|
|
||||||
<controls:SettingsExpander Header="Sonarr Settings"
|
<controls:SettingsExpander Header="Sonarr Settings"
|
||||||
|
|
@ -776,7 +925,7 @@
|
||||||
</controls:SettingsExpanderItem.Footer>
|
</controls:SettingsExpanderItem.Footer>
|
||||||
</controls:SettingsExpanderItem>
|
</controls:SettingsExpanderItem>
|
||||||
|
|
||||||
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm if traffic is routed through a VPN">
|
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm that traffic is routed through a VPN. After enabling the VPN or changing location, restart the app; otherwise, Crunchyroll may still see the old login location.">
|
||||||
<controls:SettingsExpanderItem.Footer>
|
<controls:SettingsExpanderItem.Footer>
|
||||||
<Grid VerticalAlignment="Center">
|
<Grid VerticalAlignment="Center">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|
|
||||||
33
Dockerfile.webtop
Normal file
33
Dockerfile.webtop
Normal 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
16
docker/50-crd-shortcuts
Normal 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
9
docker/crd.desktop
Normal 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
BIN
images/Settings_G_Notifications.png
(Stored with Git LFS)
Normal file
Binary file not shown.
Loading…
Reference in a new issue