mirror of
https://github.com/Crunchy-DL/Crunchy-Downloader.git
synced 2026-05-17 23:42: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 manager = ProgramManager.Instance;
|
||||
QueueManager.Instance.RestorePersistedQueue();
|
||||
|
||||
if (!isHeadless){
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
|
@ -38,7 +39,10 @@ public class App : Application{
|
|||
};
|
||||
|
||||
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
|
||||
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
|
||||
desktop.Exit += (_, _) => {
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
manager.StopBackgroundTasks();
|
||||
};
|
||||
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
|
||||
|
||||
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
|
||||
|
|
@ -148,7 +152,7 @@ public class App : Application{
|
|||
}
|
||||
|
||||
public void UpdateTrayTooltip(){
|
||||
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => e.DownloadProgress is{ Done: false, Error: false });
|
||||
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => !e.DownloadProgress.IsFinished);
|
||||
|
||||
var options = CrunchyrollManager.Instance.CrunOptions;
|
||||
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
|
||||
|
|
@ -181,4 +185,4 @@ public class App : Application{
|
|||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
CRD/Assets/app_icon.png
(Stored with Git LFS)
Normal file
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.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
||||
|
||||
(bool IsOk, string ResponseContent, string error) response;
|
||||
(bool IsOk, string ResponseContent, string error, Dictionary<string,string> Headers) response;
|
||||
if (!HttpClientReq.Instance.UseFlareSolverr){
|
||||
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
} else{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ using System.Web;
|
|||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Notifications;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Views;
|
||||
|
|
@ -22,6 +23,8 @@ using ReactiveUI;
|
|||
namespace CRD.Downloader.Crunchyroll;
|
||||
|
||||
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
|
||||
private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60);
|
||||
|
||||
public CrToken? Token;
|
||||
public CrProfile Profile = new();
|
||||
public Subscription? Subscription{ get; set; }
|
||||
|
|
@ -30,11 +33,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
|
||||
|
||||
public CrAuthSettings AuthSettings = authSettings;
|
||||
|
||||
|
||||
public Dictionary<string, CookieCollection> cookieStore = new();
|
||||
|
||||
private bool IsTokenExpiredOrNearExpiry(){
|
||||
return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer;
|
||||
}
|
||||
|
||||
public void Init(){
|
||||
|
||||
Profile = new CrProfile{
|
||||
Username = "???",
|
||||
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
|
||||
|
|
@ -406,7 +412,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
|
||||
public async Task RefreshToken(bool needsToken){
|
||||
if (EndpointEnum == CrunchyrollEndpoints.Guest){
|
||||
if (Token != null && !(DateTime.Now > Token.expires)){
|
||||
if (!IsTokenExpiredOrNearExpiry()){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -418,7 +424,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
Token.access_token != null && Token.refresh_token == null){
|
||||
await AuthAnonymous();
|
||||
} else{
|
||||
if (!(DateTime.Now > Token.expires) && needsToken){
|
||||
if (!IsTokenExpiredOrNearExpiry() && needsToken){
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -427,6 +433,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
return;
|
||||
}
|
||||
|
||||
var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != "???";
|
||||
|
||||
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
|
|
@ -464,6 +472,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
|
||||
} else{
|
||||
Console.Error.WriteLine("Refresh Token Auth Failed");
|
||||
if (hadUserSession){
|
||||
await NotificationPublisher.Instance.PublishLoginExpiredAsync(crunInstance.CrunOptions.NotificationSettings, Profile.Username, AuthSettings.Endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,9 +78,7 @@ public class CrMovies{
|
|||
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source;
|
||||
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Error = false,
|
||||
State = DownloadState.Queued,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0
|
||||
|
|
@ -91,4 +89,4 @@ public class CrMovies{
|
|||
|
||||
return epMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,9 +184,7 @@ public class CrMusic{
|
|||
epMeta.Image = images.FirstOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.ImageBig = images.FirstOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Error = false,
|
||||
State = DownloadState.Queued,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0
|
||||
|
|
@ -198,4 +196,4 @@ public class CrMusic{
|
|||
|
||||
return epMeta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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.Structs;
|
||||
using CRD.Utils.Muxing.Syncing;
|
||||
using CRD.Utils.Notifications;
|
||||
using CRD.Utils.Parser;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.Utils.Sonarr.Models;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Utils.Updater;
|
||||
using CRD.ViewModels;
|
||||
using CRD.ViewModels.Utils;
|
||||
using CRD.Views;
|
||||
|
|
@ -76,6 +78,8 @@ public class CrunchyrollManager{
|
|||
public CrSeries CrSeries;
|
||||
public CrMovies CrMovies;
|
||||
public CrMusic CrMusic;
|
||||
public CrQueue CrQueue;
|
||||
|
||||
public History History;
|
||||
|
||||
#region Singelton
|
||||
|
|
@ -110,11 +114,13 @@ public class CrunchyrollManager{
|
|||
options.UseCrBetaApi = true;
|
||||
options.AutoDownload = false;
|
||||
options.RemoveFinishedDownload = false;
|
||||
options.PersistQueue = false;
|
||||
options.Chapters = true;
|
||||
options.Hslang = "none";
|
||||
options.Force = "Y";
|
||||
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
|
||||
options.Partsize = 10;
|
||||
options.DubDownloadDelaySeconds = 0;
|
||||
options.DlSubs = new List<string>{ "en-US" };
|
||||
options.SkipMuxing = false;
|
||||
options.MkvmergeOptions = [];
|
||||
|
|
@ -127,6 +133,8 @@ public class CrunchyrollManager{
|
|||
options.CcSubsFont = "Trebuchet MS";
|
||||
options.RetryDelay = 5;
|
||||
options.RetryAttempts = 5;
|
||||
options.PlaybackRateLimitRetryDelaySeconds = 30;
|
||||
options.RetryMaxDelaySeconds = 3600;
|
||||
options.Numbers = 2;
|
||||
options.Timeout = 15000;
|
||||
options.DubLang = new List<string>(){ "ja-JP" };
|
||||
|
|
@ -158,11 +166,13 @@ public class CrunchyrollManager{
|
|||
};
|
||||
|
||||
options.History = true;
|
||||
options.HistoryRemoveMissingEpisodes = true;
|
||||
|
||||
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
|
||||
options.HistoryAutoRefreshIntervalMinutes = 0;
|
||||
|
||||
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
|
||||
options.NormalizeNotificationSettings();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
|
@ -182,6 +192,7 @@ public class CrunchyrollManager{
|
|||
CrSeries = new CrSeries();
|
||||
CrMovies = new CrMovies();
|
||||
CrMusic = new CrMusic();
|
||||
CrQueue = new CrQueue();
|
||||
History = new History();
|
||||
}
|
||||
|
||||
|
|
@ -235,30 +246,95 @@ public class CrunchyrollManager{
|
|||
Video = true,
|
||||
};
|
||||
|
||||
|
||||
var ghAuth = Updater.Instance.GhAuthJson;
|
||||
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
|
||||
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
|
||||
|
||||
if (CrunOptions.StreamEndpoint == null){
|
||||
CrunOptions.StreamEndpoint = DefaultAndroidTvAuthSettings;
|
||||
} else if (CrunOptions.StreamEndpoint.UseDefault){
|
||||
CrunOptions.StreamEndpoint.Authorization = DefaultAndroidTvAuthSettings.Authorization;
|
||||
CrunOptions.StreamEndpoint.UserAgent = DefaultAndroidTvAuthSettings.UserAgent;
|
||||
//---------- TV ----------
|
||||
CrunOptions.StreamEndpoint ??= new CrAuthSettings{
|
||||
Authorization = DefaultAndroidTvAuthSettings.Authorization,
|
||||
UserAgent = DefaultAndroidTvAuthSettings.UserAgent,
|
||||
Device_name = DefaultAndroidTvAuthSettings.Device_name,
|
||||
Device_type = DefaultAndroidTvAuthSettings.Device_type,
|
||||
UseDefault = true
|
||||
};
|
||||
|
||||
|
||||
if (CrunOptions.StreamEndpoint.UseDefault){
|
||||
var streamEndpointAuthorization = DefaultAndroidTvAuthSettings.Authorization;
|
||||
var streamEndpointUserAgent = DefaultAndroidTvAuthSettings.UserAgent;
|
||||
|
||||
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.Authorization) &&
|
||||
!string.IsNullOrEmpty(CrunOptions.StreamEndpoint.UserAgent) &&
|
||||
Helpers.CompareClientVersions(
|
||||
Helpers.ExtractClientVersion(CrunOptions.StreamEndpoint.UserAgent),
|
||||
Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
|
||||
streamEndpointAuthorization = CrunOptions.StreamEndpoint.Authorization;
|
||||
streamEndpointUserAgent = CrunOptions.StreamEndpoint.UserAgent;
|
||||
}
|
||||
|
||||
if (ghAuthTv != null &&
|
||||
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
|
||||
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
|
||||
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(streamEndpointUserAgent)) > 0){
|
||||
streamEndpointAuthorization = ghAuthTv.Authorization;
|
||||
streamEndpointUserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
|
||||
}
|
||||
|
||||
CrunOptions.StreamEndpoint.Authorization = streamEndpointAuthorization;
|
||||
CrunOptions.StreamEndpoint.UserAgent = streamEndpointUserAgent;
|
||||
CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name;
|
||||
CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type;
|
||||
}
|
||||
|
||||
|
||||
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
|
||||
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
|
||||
|
||||
if (CrunOptions.StreamEndpointSecondSettings == null){
|
||||
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
|
||||
} else if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
|
||||
CrunOptions.StreamEndpointSecondSettings.Authorization = DefaultAndroidAuthSettings.Authorization;
|
||||
CrunOptions.StreamEndpointSecondSettings.UserAgent = DefaultAndroidAuthSettings.UserAgent;
|
||||
//---------- TV ----------
|
||||
|
||||
|
||||
//---------- Mobile ----------
|
||||
CrunOptions.StreamEndpointSecondSettings ??= new CrAuthSettings{
|
||||
Authorization = DefaultAndroidAuthSettings.Authorization,
|
||||
UserAgent = DefaultAndroidAuthSettings.UserAgent,
|
||||
Device_name = DefaultAndroidAuthSettings.Device_name,
|
||||
Device_type = DefaultAndroidAuthSettings.Device_type,
|
||||
UseDefault = true
|
||||
};
|
||||
|
||||
if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
|
||||
var streamEndpointSecondAuthorization = DefaultAndroidAuthSettings.Authorization;
|
||||
var streamEndpointSecondUserAgent = DefaultAndroidAuthSettings.UserAgent;
|
||||
|
||||
if (!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.Authorization) &&
|
||||
!string.IsNullOrEmpty(CrunOptions.StreamEndpointSecondSettings.UserAgent) &&
|
||||
Helpers.CompareClientVersions(
|
||||
Helpers.ExtractClientVersion(CrunOptions.StreamEndpointSecondSettings.UserAgent),
|
||||
Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
|
||||
streamEndpointSecondAuthorization = CrunOptions.StreamEndpointSecondSettings.Authorization;
|
||||
streamEndpointSecondUserAgent = CrunOptions.StreamEndpointSecondSettings.UserAgent;
|
||||
}
|
||||
|
||||
if (ghAuthMobile != null &&
|
||||
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
|
||||
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
|
||||
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(streamEndpointSecondUserAgent)) > 0){
|
||||
streamEndpointSecondAuthorization = ghAuthMobile.Authorization;
|
||||
streamEndpointSecondUserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
|
||||
}
|
||||
|
||||
CrunOptions.StreamEndpointSecondSettings.Authorization = streamEndpointSecondAuthorization;
|
||||
CrunOptions.StreamEndpointSecondSettings.UserAgent = streamEndpointSecondUserAgent;
|
||||
CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name;
|
||||
CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type;
|
||||
}
|
||||
|
||||
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
|
||||
|
||||
//---------- Mobile ----------
|
||||
|
||||
await CrAuthEndpoint1.Auth();
|
||||
if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){
|
||||
await CrAuthEndpoint2.Auth();
|
||||
|
|
@ -337,15 +413,16 @@ public class CrunchyrollManager{
|
|||
QueueManager.Instance.ReleaseDownloadSlot(data);
|
||||
}
|
||||
|
||||
int retryAttemptCount = data.DownloadProgress.RetryAttemptCount;
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
Error = false,
|
||||
State = DownloadState.Downloading,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Starting"
|
||||
Doing = "Starting",
|
||||
RetryAttemptCount = retryAttemptCount
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
var res = new DownloadResponse();
|
||||
try{
|
||||
res = await DownloadMediaList(data, options);
|
||||
|
|
@ -354,18 +431,31 @@ public class CrunchyrollManager{
|
|||
res.Error = true;
|
||||
}
|
||||
|
||||
if (res.RetrySuggested){
|
||||
ReleaseDownloadSlotIfHeld();
|
||||
var retryDelay = TimeSpan.FromSeconds(Math.Max(1, res.RetryDelaySeconds));
|
||||
QueueManager.Instance.BlockAutoDownloadUntil(retryDelay, data.Cts.Token);
|
||||
QueueManager.Instance.ScheduleRetry(
|
||||
data,
|
||||
retryDelay,
|
||||
$"Rate limited by playback API. Retrying in {(int)retryDelay.TotalSeconds}s",
|
||||
data.Cts.Token);
|
||||
return false;
|
||||
}
|
||||
|
||||
data.DownloadProgress.ClearRetryState();
|
||||
|
||||
if (res.Error){
|
||||
ReleaseDownloadSlotIfHeld();
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Error = true,
|
||||
State = DownloadState.Error,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""),
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
await NotificationPublisher.Instance.PublishDownloadFailedAsync(CrunOptions.NotificationSettings, data, res.ErrorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -373,13 +463,13 @@ public class CrunchyrollManager{
|
|||
if (options.DownloadAllowEarlyStart){
|
||||
ReleaseDownloadSlotIfHeld();
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Waiting for Muxing/Encoding"
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
|
||||
processingSlotHeld = true;
|
||||
}
|
||||
|
|
@ -389,16 +479,17 @@ public class CrunchyrollManager{
|
|||
bool syncError = false;
|
||||
bool muxError = false;
|
||||
var notSyncedDubs = "";
|
||||
var fallbackUsed = false;
|
||||
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Muxing"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
if (options.MuxFonts){
|
||||
await FontsManager.Instance.GetFontsAsync();
|
||||
|
|
@ -459,14 +550,14 @@ public class CrunchyrollManager{
|
|||
|
||||
if (options.IsEncodeEnabled){
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Encoding"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
||||
|
||||
|
|
@ -508,6 +599,52 @@ public class CrunchyrollManager{
|
|||
},
|
||||
fileNameAndPath, data);
|
||||
|
||||
if (result.syncError && options.SyncTimingFullQualityFallback && result.failedSyncLocales.Count > 0){
|
||||
var fallbackResult = await TrySyncTimingFallbackAsync(res, data, options, result.failedSyncLocales);
|
||||
if (fallbackResult.FallbackReady){
|
||||
fallbackUsed = true;
|
||||
|
||||
if (result.isMuxed && result.merger != null){
|
||||
Helpers.DeleteFile(result.merger.Options.Output);
|
||||
}
|
||||
|
||||
result = await MuxStreams(res.Data,
|
||||
new CrunchyMuxOptions{
|
||||
DubLangList = options.DubLang,
|
||||
SubLangList = options.DlSubs,
|
||||
FfmpegOptions = options.FfmpegOptions,
|
||||
SkipSubMux = options.SkipSubsMux,
|
||||
Output = fileNameAndPath,
|
||||
Mp4 = options.Mp4,
|
||||
Mp3 = options.AudioOnlyToMp3,
|
||||
MuxFonts = options.MuxFonts,
|
||||
MuxCover = options.MuxCover,
|
||||
VideoTitle = res.VideoTitle,
|
||||
Novids = options.Novids,
|
||||
NoCleanup = options.Nocleanup,
|
||||
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
|
||||
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
|
||||
MkvmergeOptions = options.MkvmergeOptions,
|
||||
ForceMuxer = options.Force,
|
||||
SyncTiming = false,
|
||||
CcTag = options.CcTag,
|
||||
KeepAllVideos = true,
|
||||
MuxDescription = options.IncludeVideoDescription,
|
||||
DlVideoOnce = false,
|
||||
DefaultSubSigns = options.DefaultSubSigns,
|
||||
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
|
||||
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
|
||||
SignsSubsAsForced = options.SignsSubsAsForced,
|
||||
},
|
||||
fileNameAndPath, data);
|
||||
|
||||
syncError = false;
|
||||
notSyncedDubs = "";
|
||||
} else{
|
||||
result.notSyncedDubs = string.Join(", ", fallbackResult.FailedLocales);
|
||||
}
|
||||
}
|
||||
|
||||
syncError = result.syncError;
|
||||
notSyncedDubs = result.notSyncedDubs;
|
||||
muxError = !result.isMuxed && !data.OnlySubs;
|
||||
|
|
@ -516,16 +653,18 @@ public class CrunchyrollManager{
|
|||
result.merger.CleanUp();
|
||||
}
|
||||
|
||||
DeleteSyncVideoFiles(res.Data);
|
||||
|
||||
if (options.IsEncodeEnabled && !muxError){
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Encoding"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
|
||||
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
|
||||
|
|
@ -543,22 +682,22 @@ public class CrunchyrollManager{
|
|||
Language = d.Language,
|
||||
ClosedCaption = d.Cc ?? false,
|
||||
Signs = d.Signs ?? false,
|
||||
Delay = d.Delay,
|
||||
RelatedVideoDownloadMedia = d.RelatedVideoDownloadMedia
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles);
|
||||
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
Done = true,
|
||||
State = DownloadState.Done,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "")
|
||||
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") + (fallbackUsed ? " - Used full-quality fallback" : "")
|
||||
};
|
||||
|
||||
QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError);
|
||||
|
|
@ -577,8 +716,7 @@ public class CrunchyrollManager{
|
|||
}
|
||||
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
Done = true,
|
||||
State = DownloadState.Done,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
|
|
@ -597,8 +735,8 @@ public class CrunchyrollManager{
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
|
||||
var ids = data.Data.First().GetOriginalIds();
|
||||
|
|
@ -609,31 +747,14 @@ public class CrunchyrollManager{
|
|||
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
|
||||
}
|
||||
|
||||
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
|
||||
if (CrunOptions.DownloadFinishedPlaySound){
|
||||
try{
|
||||
var audioPath = CrunOptions.DownloadFinishedSoundPath;
|
||||
if (!string.IsNullOrEmpty(audioPath)){
|
||||
var player = new AudioPlayer();
|
||||
player.Play(audioPath);
|
||||
}
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine("Failed to play sound: " + exception);
|
||||
}
|
||||
}
|
||||
await NotificationPublisher.Instance.PublishDownloadFinishedAsync(CrunOptions.NotificationSettings, data);
|
||||
|
||||
if (CrunOptions.DownloadFinishedExecute){
|
||||
try{
|
||||
var filePath = CrunOptions.DownloadFinishedExecutePath;
|
||||
if (!string.IsNullOrEmpty(filePath)){
|
||||
Helpers.ExecuteFile(filePath);
|
||||
}
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine("Failed to execute file: " + exception);
|
||||
}
|
||||
}
|
||||
if (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
|
||||
await NotificationPublisher.Instance.PublishQueueFinishedAsync(CrunOptions.NotificationSettings, data);
|
||||
|
||||
if (CrunOptions.ShutdownWhenQueueEmpty){
|
||||
CrunOptions.ShutdownWhenQueueEmpty = false;
|
||||
CfgManager.WriteCrSettings();
|
||||
Helpers.ShutdownComputer();
|
||||
}
|
||||
}
|
||||
|
|
@ -644,18 +765,18 @@ public class CrunchyrollManager{
|
|||
|
||||
#region Temp Files Move
|
||||
|
||||
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles){
|
||||
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles, bool replaceExisting = false){
|
||||
if (!options.DownloadToTempFolder) return;
|
||||
|
||||
data.DownloadProgress = new DownloadProgress{
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Moving Files"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
|
||||
Console.WriteLine("Invalid or non-existent temp folder path.");
|
||||
|
|
@ -663,15 +784,15 @@ public class CrunchyrollManager{
|
|||
}
|
||||
|
||||
// Move the main output file
|
||||
await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
|
||||
await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
|
||||
|
||||
// Move the subtitle files
|
||||
foreach (var downloadedMedia in subtitles){
|
||||
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
|
||||
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, replaceExisting);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options){
|
||||
private async Task MoveFile(string sourcePath, string tempFolderPath, string downloadPath, CrDownloadOptions options, bool replaceExisting = false){
|
||||
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)){
|
||||
// Console.Error.WriteLine("Source file does not exist or path is invalid.");
|
||||
return;
|
||||
|
|
@ -691,6 +812,7 @@ public class CrunchyrollManager{
|
|||
: CfgManager.PathVIDEOS_DIR;
|
||||
|
||||
var destinationPath = Path.Combine(destinationFolder ?? string.Empty, fileName);
|
||||
destinationPath = replaceExisting ? destinationPath : Helpers.GetAvailableDestinationPath(destinationPath);
|
||||
|
||||
string? destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||
if (string.IsNullOrEmpty(destinationDirectory)){
|
||||
|
|
@ -704,7 +826,7 @@ public class CrunchyrollManager{
|
|||
}
|
||||
});
|
||||
|
||||
await Task.Run(() => File.Move(sourcePath, destinationPath));
|
||||
await Task.Run(() => File.Move(sourcePath, destinationPath, replaceExisting));
|
||||
Console.WriteLine($"File moved to {destinationPath}");
|
||||
} catch (IOException ex){
|
||||
Console.Error.WriteLine($"An error occurred while moving the file: {ex.Message}");
|
||||
|
|
@ -717,7 +839,8 @@ public class CrunchyrollManager{
|
|||
|
||||
#endregion
|
||||
|
||||
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename, CrunchyEpMeta crunchyEpMeta){
|
||||
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List<string> failedSyncLocales)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename,
|
||||
CrunchyEpMeta crunchyEpMeta){
|
||||
var muxToMp3 = false;
|
||||
|
||||
if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){
|
||||
|
|
@ -728,7 +851,7 @@ public class CrunchyrollManager{
|
|||
}
|
||||
} else{
|
||||
Console.WriteLine("Skip muxing since no videos are downloaded");
|
||||
return (null, false, false, "");
|
||||
return (null, false, false, "", []);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -771,10 +894,10 @@ public class CrunchyrollManager{
|
|||
OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
|
||||
SkipSubMux = options.SkipSubMux,
|
||||
OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput
|
||||
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
|
||||
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, Delay = a.Delay, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
|
||||
Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}",
|
||||
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
|
||||
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
|
||||
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, Delay = a.Delay, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
|
||||
KeepAllVideos = options.KeepAllVideos,
|
||||
Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) : [],
|
||||
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
|
||||
|
|
@ -797,11 +920,14 @@ public class CrunchyrollManager{
|
|||
Cover = options.MuxCover ? data.Where(a => a.Type == DownloadMediaType.Cover).Select(a => new MergerInput{ Path = a.Path ?? string.Empty }).ToList() : [],
|
||||
});
|
||||
|
||||
if (!File.Exists(CfgManager.PathFFMPEG)){
|
||||
bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
|
||||
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
|
||||
|
||||
if (!ffmpegAvailable){
|
||||
Console.Error.WriteLine("FFmpeg not found");
|
||||
}
|
||||
|
||||
if (!File.Exists(CfgManager.PathMKVMERGE)){
|
||||
if (!mkvmergeAvailable){
|
||||
Console.Error.WriteLine("MKVmerge not found");
|
||||
}
|
||||
|
||||
|
|
@ -811,14 +937,14 @@ public class CrunchyrollManager{
|
|||
|
||||
if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){
|
||||
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Muxing – Syncing Dub Timings"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
var basePath = merger.Options.OnlyVid.First().Path;
|
||||
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
|
||||
|
|
@ -845,42 +971,162 @@ public class CrunchyrollManager{
|
|||
|
||||
|
||||
var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale);
|
||||
audio?.Delay = (int)(delay.offSet * 1000);
|
||||
var delayMs = (int)(delay.offSet * 1000);
|
||||
if (audio != null){
|
||||
audio.Delay = delayMs;
|
||||
}
|
||||
|
||||
var sourceAudio = data.FirstOrDefault(downloadedMedia =>
|
||||
downloadedMedia.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription &&
|
||||
downloadedMedia.Lang.CrLocale == syncVideo.Lang.CrLocale);
|
||||
if (sourceAudio != null){
|
||||
sourceAudio.Delay = delayMs;
|
||||
}
|
||||
|
||||
|
||||
var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
|
||||
var sourceSubtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle && a.RelatedVideoDownloadMedia == syncVideo).ToList();
|
||||
|
||||
if (subtitles.Count <= 0) continue;
|
||||
|
||||
foreach (var subMergerInput in subtitles){
|
||||
subMergerInput.Delay = (int)(delay.offSet * 1000);
|
||||
subMergerInput.Delay = delayMs;
|
||||
}
|
||||
|
||||
foreach (var sourceSubtitle in sourceSubtitles){
|
||||
sourceSubtitle.Delay = delayMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncVideosList.ForEach(syncVideo => {
|
||||
if (syncVideo.Path != null) Helpers.DeleteFile(syncVideo.Path);
|
||||
});
|
||||
|
||||
crunchyEpMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Muxing"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
}
|
||||
|
||||
if (!options.Mp4 && !muxToMp3){
|
||||
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE);
|
||||
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE, crunchyEpMeta.Cts.Token);
|
||||
} else{
|
||||
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG);
|
||||
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG, crunchyEpMeta.Cts.Token);
|
||||
}
|
||||
|
||||
return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs));
|
||||
return (merger, isMuxed, syncError, string.Join(", ", notSyncedDubs), notSyncedDubs);
|
||||
}
|
||||
|
||||
private async Task<(bool FallbackReady, List<string> FailedLocales)> TrySyncTimingFallbackAsync(DownloadResponse res, CrunchyEpMeta data, CrDownloadOptions options, List<string> failedSyncLocales){
|
||||
var uniqueFailedLocales = failedSyncLocales
|
||||
.Where(locale => !string.IsNullOrWhiteSpace(locale))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (uniqueFailedLocales.Count == 0){
|
||||
return (false, []);
|
||||
}
|
||||
|
||||
data.DownloadProgress = new DownloadProgress{
|
||||
State = DownloadState.Downloading,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = $"Downloading full-quality fallback video ({string.Join(", ", uniqueFailedLocales)})"
|
||||
};
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
foreach (var syncVideo in res.Data.Where(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase)).ToList()){
|
||||
if (!string.IsNullOrEmpty(syncVideo.Path)){
|
||||
Helpers.DeleteFile(syncVideo.Path);
|
||||
Helpers.DeleteFile(syncVideo.Path + ".resume");
|
||||
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
|
||||
}
|
||||
}
|
||||
|
||||
res.Data.RemoveAll(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
var fallbackOptions = Helpers.DeepCopy(options) ?? new CrDownloadOptions();
|
||||
|
||||
fallbackOptions.DubLang = uniqueFailedLocales;
|
||||
fallbackOptions.Noaudio = true;
|
||||
fallbackOptions.Novids = false;
|
||||
fallbackOptions.DlSubs = [];
|
||||
fallbackOptions.SkipSubs = true;
|
||||
fallbackOptions.SkipSubsMux = true;
|
||||
fallbackOptions.Chapters = false;
|
||||
fallbackOptions.IncludeVideoDescription = false;
|
||||
fallbackOptions.MuxCover = false;
|
||||
fallbackOptions.DownloadDescriptionAudio = false;
|
||||
fallbackOptions.DlVideoOnce = false;
|
||||
fallbackOptions.SyncTiming = false;
|
||||
fallbackOptions.DownloadFirstAvailableDub = false;
|
||||
fallbackOptions.SkipMuxing = true;
|
||||
|
||||
var originalDataEntries = data.Data;
|
||||
var originalSelectedDubs = data.SelectedDubs != null ? new List<string>(data.SelectedDubs) : null;
|
||||
var originalDownloadSubs = new List<string>(data.DownloadSubs);
|
||||
var originalTempFileSuffix = data.TempFileSuffix;
|
||||
|
||||
data.Data = data.Data
|
||||
.Where(epMeta => epMeta.Lang != null && uniqueFailedLocales.Contains(epMeta.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
data.SelectedDubs = uniqueFailedLocales;
|
||||
data.DownloadSubs = [];
|
||||
data.TempFileSuffix = $"-fallback-{Guid.NewGuid():N}"[..19];
|
||||
|
||||
if (data.Data.Count == 0){
|
||||
data.Data = originalDataEntries;
|
||||
data.SelectedDubs = originalSelectedDubs;
|
||||
data.DownloadSubs = originalDownloadSubs;
|
||||
data.TempFileSuffix = originalTempFileSuffix;
|
||||
return (false, uniqueFailedLocales);
|
||||
}
|
||||
|
||||
DownloadResponse fallbackResponse;
|
||||
try{
|
||||
fallbackResponse = await DownloadMediaList(data, fallbackOptions);
|
||||
} finally{
|
||||
data.Data = originalDataEntries;
|
||||
data.SelectedDubs = originalSelectedDubs;
|
||||
data.DownloadSubs = originalDownloadSubs;
|
||||
data.TempFileSuffix = originalTempFileSuffix;
|
||||
}
|
||||
|
||||
if (fallbackResponse.Error){
|
||||
return (false, uniqueFailedLocales);
|
||||
}
|
||||
|
||||
var fallbackVideos = fallbackResponse.Data
|
||||
.Where(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
var fallbackVideoLocales = fallbackVideos
|
||||
.Select(media => media.Lang.CrLocale)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (fallbackVideoLocales.Count != uniqueFailedLocales.Count){
|
||||
return (false, uniqueFailedLocales);
|
||||
}
|
||||
|
||||
res.Data.RemoveAll(media => media.Type == DownloadMediaType.Video && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase));
|
||||
res.Data.AddRange(fallbackVideos);
|
||||
|
||||
return (true, uniqueFailedLocales);
|
||||
}
|
||||
|
||||
private static void DeleteSyncVideoFiles(List<DownloadedMedia> data){
|
||||
foreach (var syncVideo in data.Where(media => media.Type == DownloadMediaType.SyncVideo).ToList()){
|
||||
if (!string.IsNullOrEmpty(syncVideo.Path)){
|
||||
Helpers.DeleteFile(syncVideo.Path);
|
||||
Helpers.DeleteFile(syncVideo.Path + ".resume");
|
||||
Helpers.DeleteFile(syncVideo.Path + ".new.resume");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResponse> DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){
|
||||
|
|
@ -894,8 +1140,11 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
|
||||
bool ffmpegAvailable = Helpers.IsInstalled(CfgManager.PathFFMPEG, "-version");
|
||||
bool mkvmergeAvailable = Helpers.IsInstalled(CfgManager.PathMKVMERGE, "--version");
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
|
||||
if (!File.Exists(CfgManager.PathFFMPEG)){
|
||||
if (!ffmpegAvailable){
|
||||
Console.Error.WriteLine("Missing ffmpeg");
|
||||
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
|
||||
"https://github.com/GyanD/codexffmpeg/releases/latest");
|
||||
|
|
@ -907,7 +1156,7 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
|
||||
if (!File.Exists(CfgManager.PathMKVMERGE)){
|
||||
if (!mkvmergeAvailable){
|
||||
Console.Error.WriteLine("Missing Mkvmerge");
|
||||
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
|
||||
"https://mkvtoolnix.download/downloads.html#windows");
|
||||
|
|
@ -919,7 +1168,7 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
} else{
|
||||
if (!Helpers.IsInstalled("ffmpeg", "-version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "ffmpeg"))){
|
||||
if (!ffmpegAvailable){
|
||||
Console.Error.WriteLine("Ffmpeg is not installed or not in the system PATH.");
|
||||
MainWindow.Instance.ShowError("Ffmpeg is not installed on the system or not found in the PATH.");
|
||||
return new DownloadResponse{
|
||||
|
|
@ -930,7 +1179,7 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
|
||||
if (!Helpers.IsInstalled("mkvmerge", "--version") && !File.Exists(Path.Combine(AppContext.BaseDirectory, "lib", "mkvmerge"))){
|
||||
if (!mkvmergeAvailable){
|
||||
Console.Error.WriteLine("Mkvmerge is not installed or not in the system PATH.");
|
||||
MainWindow.Instance.ShowError("Mkvmerge is not installed on the system or not found in the PATH.");
|
||||
return new DownloadResponse{
|
||||
|
|
@ -991,16 +1240,16 @@ public class CrunchyrollManager{
|
|||
if (data.Data is{ Count: > 0 }){
|
||||
options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
|
||||
|
||||
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty ,data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
|
||||
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty, data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
|
||||
SonarrEpisode? sonarrEpisode;
|
||||
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
|
||||
sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId));
|
||||
if(sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
|
||||
if (sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
|
||||
variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true));
|
||||
variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
|
||||
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
|
||||
}
|
||||
|
||||
|
||||
if (options.DownloadDescriptionAudio){
|
||||
var alreadyAdr = new HashSet<string>(
|
||||
data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err")
|
||||
|
|
@ -1044,7 +1293,16 @@ public class CrunchyrollManager{
|
|||
|
||||
data.Data = sortedMetaData;
|
||||
|
||||
var epMetaIndex = 0;
|
||||
foreach (CrunchyEpMetaData epMeta in data.Data){
|
||||
if (epMetaIndex > 0 && options.DubDownloadDelaySeconds > 0){
|
||||
var delay = TimeSpan.FromSeconds(options.DubDownloadDelaySeconds);
|
||||
data.DownloadProgress.Doing = $"Waiting {options.DubDownloadDelaySeconds}s before next dub";
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
await Task.Delay(delay, data.Cts.Token);
|
||||
}
|
||||
|
||||
epMetaIndex++;
|
||||
Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}");
|
||||
|
||||
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
|
||||
|
|
@ -1121,8 +1379,8 @@ public class CrunchyrollManager{
|
|||
|
||||
#endregion
|
||||
|
||||
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData = default;
|
||||
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default;
|
||||
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData = default;
|
||||
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers) fetchPlaybackData2 = default;
|
||||
|
||||
if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){
|
||||
fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint);
|
||||
|
|
@ -1133,7 +1391,9 @@ public class CrunchyrollManager{
|
|||
}
|
||||
|
||||
if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){
|
||||
var errorJson = fetchPlaybackData.error;
|
||||
var errorJson = !string.IsNullOrEmpty(fetchPlaybackData.error)
|
||||
? fetchPlaybackData.error
|
||||
: fetchPlaybackData2.error;
|
||||
if (!string.IsNullOrEmpty(errorJson)){
|
||||
var error = StreamError.FromJson(errorJson);
|
||||
|
||||
|
|
@ -1147,7 +1407,7 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
|
||||
if (error?.Error.Contains("Account maturity rating is lower than video rating") == true ||
|
||||
if (error?.Error?.Contains("Account maturity rating is lower than video rating") == true ||
|
||||
errorJson.Contains("Account maturity rating is lower than video rating")){
|
||||
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
|
||||
return new DownloadResponse{
|
||||
|
|
@ -1158,6 +1418,26 @@ public class CrunchyrollManager{
|
|||
};
|
||||
}
|
||||
|
||||
if (error?.IsPlaybackRateLimitError() == true){
|
||||
int retryDelaySeconds = Helpers.GetRetryDelaySeconds(options, data.DownloadProgress.RetryAttemptCount);
|
||||
|
||||
if (fetchPlaybackData.Headers != null &&
|
||||
fetchPlaybackData.Headers.TryGetValue("retry-after", out var retryAfter) &&
|
||||
int.TryParse(retryAfter, out var parsedRetryAfter)){
|
||||
Console.WriteLine($"Retry after: {parsedRetryAfter} seconds");
|
||||
retryDelaySeconds = parsedRetryAfter;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Playback API rate limited with 4294. Requeueing download in {retryDelaySeconds}s.");
|
||||
return new DownloadResponse{
|
||||
Data = new List<DownloadedMedia>(),
|
||||
RetrySuggested = true,
|
||||
RetryDelaySeconds = retryDelaySeconds,
|
||||
FileName = "./unknown",
|
||||
ErrorText = "Rate limit error"
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(error?.Error)){
|
||||
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
|
||||
return new DownloadResponse{
|
||||
|
|
@ -1652,7 +1932,8 @@ public class CrunchyrollManager{
|
|||
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
|
||||
|
||||
string tempFile = Path.Combine(FileNameManager
|
||||
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}", variables, options.Numbers, options.FileNameWhitespaceSubstitute,
|
||||
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}{data.TempFileSuffix ?? string.Empty}", variables, options.Numbers,
|
||||
options.FileNameWhitespaceSubstitute,
|
||||
options.Override)
|
||||
.ToArray());
|
||||
string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile);
|
||||
|
|
@ -1743,13 +2024,13 @@ public class CrunchyrollManager{
|
|||
|
||||
if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Downloading,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Decrypting"
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
Console.WriteLine("Decryption Needed, attempting to decrypt");
|
||||
|
||||
|
|
@ -1834,13 +2115,13 @@ public class CrunchyrollManager{
|
|||
if (videoDownloaded){
|
||||
Console.WriteLine("Started decrypting video");
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Downloading,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Decrypting video"
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
||||
commandVideo, tempTsFileWorkDir);
|
||||
|
||||
|
|
@ -1864,18 +2145,8 @@ public class CrunchyrollManager{
|
|||
|
||||
Console.WriteLine("Decryption done for video");
|
||||
if (!options.Nocleanup){
|
||||
try{
|
||||
if (File.Exists($"{tempTsFile}.video.enc.m4s")){
|
||||
File.Delete($"{tempTsFile}.video.enc.m4s");
|
||||
}
|
||||
|
||||
if (File.Exists($"{tempTsFile}.video.enc.m4s.resume")){
|
||||
File.Delete($"{tempTsFile}.video.enc.m4s.resume");
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.WriteLine($"Failed to delete file {tempTsFile}.video.enc.m4s. Error: {ex.Message}");
|
||||
// Handle exceptions if you need to log them or throw
|
||||
}
|
||||
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s");
|
||||
Helpers.DeleteFile($"{tempTsFile}.video.enc.m4s.resume");
|
||||
}
|
||||
|
||||
try{
|
||||
|
|
@ -1905,13 +2176,13 @@ public class CrunchyrollManager{
|
|||
if (audioDownloaded){
|
||||
Console.WriteLine("Started decrypting audio");
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Downloading,
|
||||
Percent = 100,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Decrypting audio"
|
||||
};
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
|
||||
commandAudio, tempTsFileWorkDir);
|
||||
|
||||
|
|
@ -1935,18 +2206,8 @@ public class CrunchyrollManager{
|
|||
|
||||
Console.WriteLine("Decryption done for audio");
|
||||
if (!options.Nocleanup){
|
||||
try{
|
||||
if (File.Exists($"{tempTsFile}.audio.enc.m4s")){
|
||||
File.Delete($"{tempTsFile}.audio.enc.m4s");
|
||||
}
|
||||
|
||||
if (File.Exists($"{tempTsFile}.audio.enc.m4s.resume")){
|
||||
File.Delete($"{tempTsFile}.audio.enc.m4s.resume");
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.WriteLine($"Failed to delete file {tempTsFile}.audio.enc.m4s. Error: {ex.Message}");
|
||||
// Handle exceptions if you need to log them or throw
|
||||
}
|
||||
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s");
|
||||
Helpers.DeleteFile($"{tempTsFile}.audio.enc.m4s.resume");
|
||||
}
|
||||
|
||||
try{
|
||||
|
|
@ -2480,7 +2741,7 @@ public class CrunchyrollManager{
|
|||
|
||||
#region Fetch Playback Data
|
||||
|
||||
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc,
|
||||
private async Task<(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> Headers)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc,
|
||||
CrAuthSettings optionsStreamEndpointSettings){
|
||||
var temppbData = new PlaybackData{
|
||||
Total = 0,
|
||||
|
|
@ -2514,16 +2775,17 @@ public class CrunchyrollManager{
|
|||
}
|
||||
}
|
||||
|
||||
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent);
|
||||
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent, playbackRequestResponse.Headers);
|
||||
}
|
||||
|
||||
private async Task<(bool IsOk, string ResponseContent, string error)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){
|
||||
private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){
|
||||
var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null);
|
||||
request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent);
|
||||
return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore);
|
||||
}
|
||||
|
||||
private async Task<(bool IsOk, string ResponseContent, string error)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent, string error) response, string endpoint, CrAuth authEndpoint){
|
||||
private async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> HandleStreamErrorsAsync(
|
||||
(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers) response, string endpoint, CrAuth authEndpoint){
|
||||
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
|
||||
|
||||
var error = StreamError.FromJson(response.ResponseContent);
|
||||
|
|
@ -2737,4 +2999,4 @@ public class CrunchyrollManager{
|
|||
private static string BuildShakaKeysParam(List<ContentKey> keys) =>
|
||||
"--enable_raw_key_decryption " + string.Join(" ",
|
||||
keys.Select(k => $"--keys key_id={FormatKey(k.KeyID)}:key={FormatKey(k.Bytes)}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
|
|
@ -18,6 +18,7 @@ using CRD.Utils.Sonarr;
|
|||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Utils.Updater;
|
||||
using CRD.ViewModels;
|
||||
using CRD.ViewModels.Utils;
|
||||
using CRD.Views.Utils;
|
||||
|
|
@ -91,6 +92,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _syncTimings;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _syncTimingsFullQualityFallback;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _defaultSubSigns;
|
||||
|
||||
|
|
@ -115,6 +119,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private double? _partSize;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _dubDownloadDelaySeconds;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _fileName = "";
|
||||
|
||||
|
|
@ -352,6 +359,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _markAsWatched;
|
||||
|
||||
public string FontMuxDisclaimer =>
|
||||
$"Crunchyroll no longer provides the old libass font files. Font muxing now uses installed system fonts and custom files from {CfgManager.PathFONTS_DIR}. If subtitles still report missing fonts, add those files there manually.";
|
||||
|
||||
private bool settingsLoaded;
|
||||
|
||||
public CrunchyrollSettingsViewModel(){
|
||||
|
|
@ -467,6 +477,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
|
||||
DefaultSubSigns = options.DefaultSubSigns;
|
||||
PartSize = options.Partsize;
|
||||
DubDownloadDelaySeconds = options.DubDownloadDelaySeconds;
|
||||
IncludeEpisodeDescription = options.IncludeVideoDescription;
|
||||
FileTitle = options.VideoTitle ?? "";
|
||||
IncludeSignSubs = options.IncludeSignsSubs;
|
||||
|
|
@ -483,6 +494,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
MuxTypesettingFonts = options.MuxTypesettingFonts;
|
||||
MuxCover = options.MuxCover;
|
||||
SyncTimings = options.SyncTiming;
|
||||
SyncTimingsFullQualityFallback = options.SyncTimingFullQualityFallback;
|
||||
SkipSubMux = options.SkipSubsMux;
|
||||
LeadingNumbers = options.Numbers;
|
||||
FileName = options.FileName;
|
||||
|
|
@ -559,6 +571,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
|
||||
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
|
||||
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
|
||||
CrunchyrollManager.Instance.CrunOptions.SyncTimingFullQualityFallback = SyncTimingsFullQualityFallback;
|
||||
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
|
||||
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);
|
||||
CrunchyrollManager.Instance.CrunOptions.FileName = FileName;
|
||||
|
|
@ -566,6 +579,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
|
||||
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
|
||||
CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000);
|
||||
CrunchyrollManager.Instance.CrunOptions.DubDownloadDelaySeconds = Math.Max((int)(DubDownloadDelaySeconds ?? 0), 0);
|
||||
CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic;
|
||||
|
||||
CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
|
||||
|
|
@ -776,7 +790,18 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public void ResetEndpointSettings(){
|
||||
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
|
||||
var ghAuth = Updater.Instance.GhAuthJson;
|
||||
|
||||
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
|
||||
if (ghAuthMobile != null &&
|
||||
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
|
||||
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
|
||||
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
|
||||
defaultSettings.Authorization = ghAuthMobile.Authorization;
|
||||
defaultSettings.UserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
|
||||
}
|
||||
|
||||
|
||||
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
||||
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
|
||||
|
||||
|
|
@ -792,6 +817,15 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
public void ResetFirstEndpointSettings(){
|
||||
|
||||
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings;
|
||||
var ghAuth = Updater.Instance.GhAuthJson;
|
||||
var ghAuthTv = ghAuth.FirstOrDefault(e => e.Type.Equals("tv"));
|
||||
if (ghAuthTv != null &&
|
||||
!string.IsNullOrEmpty(ghAuthTv.Authorization) &&
|
||||
!string.IsNullOrEmpty(ghAuthTv.VersionName) &&
|
||||
Helpers.CompareClientVersions(ghAuthTv.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
|
||||
defaultSettings.Authorization = ghAuthTv.Authorization;
|
||||
defaultSettings.UserAgent = $"ANDROIDTV/{ghAuthTv.VersionName} Android/16";
|
||||
}
|
||||
|
||||
ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
|
||||
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];
|
||||
|
|
@ -886,4 +920,4 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,6 +255,17 @@
|
|||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
<controls:SettingsExpanderItem Content="Dub Download Delay"
|
||||
Description="Delay in seconds before starting the next selected dub. 0 disables the delay.">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<controls:NumberBox Minimum="0"
|
||||
Value="{Binding DubDownloadDelaySeconds}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
<controls:SettingsExpanderItem Content="Stream Endpoint ">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel>
|
||||
|
|
@ -585,6 +596,12 @@
|
|||
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox>
|
||||
|
||||
<CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox>
|
||||
<TextBlock IsVisible="{Binding MuxFonts}"
|
||||
MaxWidth="360"
|
||||
Margin="0,6,0,0"
|
||||
Opacity="0.85"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding FontMuxDisclaimer}" />
|
||||
</StackPanel>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
|
@ -634,6 +651,15 @@
|
|||
</ItemsControl.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="8"
|
||||
IsVisible="{Binding SyncTimings}">
|
||||
<TextBlock VerticalAlignment="Center"
|
||||
Text="Mux failed dubs in selected quality" />
|
||||
<CheckBox VerticalAlignment="Center"
|
||||
IsChecked="{Binding SyncTimingsFullQualityFallback}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
|
|
@ -821,4 +847,4 @@
|
|||
</ScrollViewer>
|
||||
|
||||
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -59,31 +59,59 @@ public class History{
|
|||
if (parsedSeries.Data != null){
|
||||
var result = false;
|
||||
foreach (var s in parsedSeries.Data){
|
||||
var sId = s.Id;
|
||||
if (s.Versions is{ Count: > 0 }){
|
||||
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){
|
||||
if (sVersion.Guid != null){
|
||||
sId = sVersion.Guid;
|
||||
}
|
||||
var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
|
||||
? crunInstance.DefaultLocale
|
||||
: crunInstance.CrunOptions.HistoryLang;
|
||||
|
||||
break;
|
||||
}
|
||||
var candidateIds = new List<string>();
|
||||
|
||||
if (s.Versions is{ Count: > 0 }){
|
||||
candidateIds.AddRange(
|
||||
s.Versions
|
||||
.Where(v => v.Original == true && !string.IsNullOrWhiteSpace(v.Guid))
|
||||
.OrderByDescending(v => v.Guid!.Length)
|
||||
.Select(v => v.Guid!)
|
||||
);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(seasonId) && sId != seasonId) continue;
|
||||
if (!string.IsNullOrWhiteSpace(s.Id)){
|
||||
candidateIds.Add(s.Id);
|
||||
}
|
||||
|
||||
candidateIds = candidateIds
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(sId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
||||
if (!string.IsNullOrEmpty(seasonId) &&
|
||||
!candidateIds.Contains(seasonId, StringComparer.OrdinalIgnoreCase)){
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seasonData.Data is{ Count: > 0 }){
|
||||
result = true;
|
||||
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
||||
foreach (var candidateId in candidateIds){
|
||||
try{
|
||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(candidateId, lang, true);
|
||||
|
||||
if (seasonData.Data is{ Count: > 0 }){
|
||||
result = true;
|
||||
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
||||
break;
|
||||
}
|
||||
} catch{
|
||||
// optional: log candidateId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
if (historySeries != null){
|
||||
RemoveUnavailableEpisodes(historySeries);
|
||||
if (historySeries.Seasons.Count == 0){
|
||||
crunInstance.HistoryList.Remove(historySeries);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
return result;
|
||||
}
|
||||
|
||||
MatchHistorySeriesWithSonarr(false);
|
||||
await MatchHistoryEpisodesWithSonarr(false, historySeries);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
|
|
@ -263,10 +291,7 @@ public class History{
|
|||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
if (historySeries != null){
|
||||
historySeries.HistorySeriesAddDate ??= DateTime.Now;
|
||||
historySeries.SeriesType = firstEpisode.GetSeriesType();
|
||||
historySeries.SeriesStreamingService = StreamingService.Crunchyroll;
|
||||
|
||||
await RefreshSeriesData(seriesId, historySeries);
|
||||
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == firstEpisode.GetSeasonId());
|
||||
|
||||
if (historySeason != null){
|
||||
|
|
@ -307,6 +332,7 @@ public class History{
|
|||
historyEpisode.EpisodeSeasonNum = historySource.GetSeasonNum();
|
||||
historyEpisode.EpisodeCrPremiumAirDate = historySource.GetAvailableDate();
|
||||
historyEpisode.EpisodeType = historySource.GetEpisodeType();
|
||||
historyEpisode.EpisodeSeriesType = historySource.GetSeriesType();
|
||||
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
|
||||
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
|
||||
|
||||
|
|
@ -325,6 +351,9 @@ public class History{
|
|||
newSeason.Init();
|
||||
}
|
||||
|
||||
historySeries.SeriesType = InferSeriesType(historySeries);
|
||||
await RefreshSeriesData(seriesId, historySeries);
|
||||
_ = historySeries.LoadImage();
|
||||
historySeries.UpdateNewEpisodes();
|
||||
} else if (!string.IsNullOrEmpty(seriesId)){
|
||||
historySeries = new HistorySeries{
|
||||
|
|
@ -332,7 +361,7 @@ public class History{
|
|||
SeriesId = firstEpisode.GetSeriesId(),
|
||||
Seasons = [],
|
||||
HistorySeriesAddDate = DateTime.Now,
|
||||
SeriesType = firstEpisode.GetSeriesType(),
|
||||
SeriesType = SeriesType.Unknown,
|
||||
SeriesStreamingService = StreamingService.Crunchyroll
|
||||
};
|
||||
crunInstance.HistoryList.Add(historySeries);
|
||||
|
|
@ -341,9 +370,10 @@ public class History{
|
|||
|
||||
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
|
||||
await RefreshSeriesData(seriesId, historySeries);
|
||||
|
||||
historySeries.Seasons.Add(newSeason);
|
||||
historySeries.SeriesType = InferSeriesType(historySeries);
|
||||
await RefreshSeriesData(seriesId, historySeries);
|
||||
_ = historySeries.LoadImage();
|
||||
historySeries.UpdateNewEpisodes();
|
||||
historySeries.Init();
|
||||
newSeason.Init();
|
||||
|
|
@ -515,7 +545,7 @@ public class History{
|
|||
|
||||
private async Task RefreshSeriesData(string seriesId, HistorySeries historySeries){
|
||||
if (cachedSeries == null || (!string.IsNullOrEmpty(cachedSeries.SeriesId) && cachedSeries.SeriesId != seriesId)){
|
||||
if (historySeries.SeriesType == SeriesType.Series){
|
||||
if (historySeries.SeriesType is SeriesType.Series or SeriesType.Movie){
|
||||
var seriesData = await crunInstance.CrSeries.SeriesById(seriesId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
||||
if (seriesData is{ Data: not null }){
|
||||
var firstEpisode = seriesData.Data.First();
|
||||
|
|
@ -658,6 +688,31 @@ public class History{
|
|||
return null;
|
||||
}
|
||||
|
||||
private static SeriesType InferSeriesType(HistorySeries? historySeries){
|
||||
var seriesTypes = new List<SeriesType>();
|
||||
|
||||
if (historySeries != null){
|
||||
seriesTypes.AddRange(historySeries.Seasons
|
||||
.SelectMany(season => season.EpisodesList)
|
||||
.Select(episode => episode.EpisodeSeriesType)
|
||||
.Where(type => type != SeriesType.Unknown));
|
||||
}
|
||||
|
||||
if (seriesTypes.Count == 0){
|
||||
return historySeries?.SeriesType ?? SeriesType.Unknown;
|
||||
}
|
||||
|
||||
if (seriesTypes.All(type => type == SeriesType.Artist)){
|
||||
return SeriesType.Artist;
|
||||
}
|
||||
|
||||
if (seriesTypes.All(type => type == SeriesType.Movie)){
|
||||
return SeriesType.Movie;
|
||||
}
|
||||
|
||||
return SeriesType.Series;
|
||||
}
|
||||
|
||||
|
||||
private string GetSeriesThumbnail(CrSeriesBase series){
|
||||
// var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
||||
|
|
@ -670,6 +725,39 @@ public class History{
|
|||
return "";
|
||||
}
|
||||
|
||||
private void RemoveUnavailableEpisodes(HistorySeries historySeries){
|
||||
if (!crunInstance.CrunOptions.HistoryRemoveMissingEpisodes){
|
||||
return;
|
||||
}
|
||||
|
||||
var seasonsToRemove = new List<HistorySeason>();
|
||||
|
||||
foreach (var season in historySeries.Seasons){
|
||||
var unavailableEpisodes = season.EpisodesList
|
||||
.Where(episode => !episode.IsEpisodeAvailableOnStreamingService)
|
||||
.ToList();
|
||||
|
||||
foreach (var episode in unavailableEpisodes){
|
||||
season.EpisodesList.Remove(episode);
|
||||
}
|
||||
|
||||
if (season.EpisodesList.Count == 0){
|
||||
seasonsToRemove.Add(season);
|
||||
continue;
|
||||
}
|
||||
|
||||
season.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
season.UpdateDownloaded();
|
||||
}
|
||||
|
||||
foreach (var season in seasonsToRemove){
|
||||
historySeries.Seasons.Remove(season);
|
||||
}
|
||||
|
||||
historySeries.UpdateNewEpisodes();
|
||||
SortSeasons(historySeries);
|
||||
}
|
||||
|
||||
|
||||
private HistorySeason NewHistorySeason(List<IHistorySource> episodeList, IHistorySource firstEpisode){
|
||||
var newSeason = new HistorySeason{
|
||||
|
|
@ -696,6 +784,7 @@ public class History{
|
|||
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
|
||||
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
|
||||
EpisodeType = historySource.GetEpisodeType(),
|
||||
EpisodeSeriesType = historySource.GetSeriesType(),
|
||||
IsEpisodeAvailableOnStreamingService = true,
|
||||
ThumbnailImageUrl = historySource.GetImageUrl(),
|
||||
};
|
||||
|
|
@ -775,7 +864,7 @@ public class History{
|
|||
List<HistoryEpisode> failedEpisodes = [];
|
||||
|
||||
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
|
||||
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
|
||||
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) || rematchAll){
|
||||
// Create a copy of the episodes list for each thread
|
||||
var episodesCopy = new List<SonarrEpisode>(episodes);
|
||||
|
||||
|
|
@ -989,4 +1078,4 @@ public class NumericStringPropertyComparer : IComparer<HistoryEpisode>{
|
|||
// Fall back to string comparison if not parseable as doubles
|
||||
return string.Compare(x?.Episode, y?.Episode, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Notifications;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
|
|
@ -54,12 +55,14 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
#region Startup Param Variables
|
||||
|
||||
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
|
||||
bool historyRefreshAdded = false;
|
||||
bool historyRefreshAdded;
|
||||
private bool exitOnTaskFinish;
|
||||
|
||||
#endregion
|
||||
|
||||
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
|
||||
private bool historyRefreshNotificationsArmed;
|
||||
private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10);
|
||||
|
||||
public IStorageProvider? StorageProvider;
|
||||
|
||||
|
|
@ -100,7 +103,6 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
internal async Task RefreshHistory(FilterType filterType){
|
||||
FetchingData = true;
|
||||
|
||||
|
||||
List<HistorySeries> filteredItems;
|
||||
var historyList = CrunchyrollManager.Instance.HistoryList;
|
||||
|
||||
|
|
@ -149,6 +151,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
|
||||
FetchingData = false;
|
||||
CrunchyrollManager.Instance.History.SortItems();
|
||||
await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance);
|
||||
}
|
||||
|
||||
private async Task AddMissingToQueue(){
|
||||
|
|
@ -158,7 +161,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
|
||||
while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
|
||||
Console.WriteLine("Waiting for downloads to complete...");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
|
@ -186,23 +189,27 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
return;
|
||||
}
|
||||
|
||||
var tasks = crunchyManager.HistoryList
|
||||
.Select(item => item.AddNewMissingToDownloads(true));
|
||||
if (crunOptions.HistoryAutoRefreshAddToQueue){
|
||||
var tasks = crunchyManager.HistoryList
|
||||
.Select(item => item.AddNewMissingToDownloads(true));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
if (Application.Current is App app){
|
||||
Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
|
||||
}
|
||||
|
||||
historyRefreshNotificationsArmed = true;
|
||||
}
|
||||
|
||||
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
|
||||
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
|
||||
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
|
||||
2000, null, true);
|
||||
if (newEpisodesBase is{ Data.Count: > 0 }){
|
||||
var newEpisodes = newEpisodesBase.Data ?? [];
|
||||
var newEpisodes = newEpisodesBase?.Data ?? [];
|
||||
|
||||
if (newEpisodesBase is{ Data.Count: > 0 }){
|
||||
try{
|
||||
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
|
|
@ -210,6 +217,114 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
Console.Error.WriteLine("Failed to update History: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
await PublishTrackedSeriesReleaseNotificationsAsync(crunchyManager, newEpisodes);
|
||||
}
|
||||
|
||||
private async Task PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager crunchyManager, List<CrBrowseEpisode>? releaseFeedEpisodes = null){
|
||||
var currentCheckTimeUtc = DateTime.UtcNow;
|
||||
var settings = crunchyManager.CrunOptions;
|
||||
var previousCheckUtc = settings.TrackedSeriesReleaseLastCheckUtc;
|
||||
|
||||
if (!historyRefreshNotificationsArmed){
|
||||
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||
CfgManager.WriteCrSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
var trackedSeries = crunchyManager.HistoryList
|
||||
.Where(series => !string.IsNullOrWhiteSpace(series.SeriesId))
|
||||
.ToDictionary(series => series.SeriesId!, StringComparer.Ordinal);
|
||||
|
||||
if (trackedSeries.Count == 0){
|
||||
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||
CfgManager.WriteCrSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
releaseFeedEpisodes ??= (await crunchyManager.CrEpisode.GetNewEpisodes(
|
||||
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang,
|
||||
2000, null, true))?.Data ?? [];
|
||||
|
||||
var notificationSettings = settings.NotificationSettings;
|
||||
var historyUpdated = false;
|
||||
var windowStartUtc = previousCheckUtc?.Subtract(TrackedSeriesReleaseOverlap);
|
||||
|
||||
foreach (var release in releaseFeedEpisodes){
|
||||
var seriesId = release.EpisodeMetadata?.SeriesId;
|
||||
if (string.IsNullOrWhiteSpace(seriesId) || !trackedSeries.TryGetValue(seriesId, out var historySeries)){
|
||||
continue;
|
||||
}
|
||||
|
||||
var releaseDateUtc = GetTrackedReleaseDateUtc(release);
|
||||
if (windowStartUtc.HasValue && releaseDateUtc < windowStartUtc.Value){
|
||||
continue;
|
||||
}
|
||||
|
||||
if (releaseDateUtc > currentCheckTimeUtc){
|
||||
continue;
|
||||
}
|
||||
|
||||
var historyEpisode = crunchyManager.History.GetHistoryEpisode(seriesId, release.EpisodeMetadata.SeasonId, release.Id ?? string.Empty);
|
||||
if (historyEpisode == null && !string.IsNullOrWhiteSpace(release.Id)){
|
||||
historyEpisode = historySeries.Seasons
|
||||
.SelectMany(season => season.EpisodesList)
|
||||
.FirstOrDefault(episode => episode.EpisodeId == release.Id);
|
||||
}
|
||||
|
||||
if (historyEpisode == null || historyEpisode.TrackedSeriesReleaseNotified){
|
||||
continue;
|
||||
}
|
||||
|
||||
var notificationSent = await NotificationPublisher.Instance.PublishTrackedSeriesEpisodeReleasedAsync(
|
||||
notificationSettings,
|
||||
historySeries,
|
||||
historyEpisode,
|
||||
release,
|
||||
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang
|
||||
);
|
||||
if (notificationSent){
|
||||
historyEpisode.TrackedSeriesReleaseNotified = true;
|
||||
historyUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc;
|
||||
|
||||
if (historyUpdated){
|
||||
CfgManager.UpdateHistoryFile();
|
||||
}
|
||||
|
||||
CfgManager.WriteCrSettings();
|
||||
}
|
||||
|
||||
private static DateTime GetTrackedReleaseDateUtc(CrBrowseEpisode episode){
|
||||
DateTime episodeAirDate = episode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
|
||||
? episode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
|
||||
: episode.EpisodeMetadata.EpisodeAirDate;
|
||||
|
||||
DateTime premiumAvailableStart = episode.EpisodeMetadata.PremiumAvailableDate.Kind == DateTimeKind.Utc
|
||||
? episode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime()
|
||||
: episode.EpisodeMetadata.PremiumAvailableDate;
|
||||
|
||||
DateTime now = DateTime.Now;
|
||||
DateTime oneYearFromNow = now.AddYears(1);
|
||||
|
||||
var targetDate = premiumAvailableStart;
|
||||
|
||||
if (targetDate >= oneYearFromNow){
|
||||
DateTime freeAvailableStart = episode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
|
||||
? episode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
|
||||
: episode.EpisodeMetadata.FreeAvailableDate;
|
||||
|
||||
if (freeAvailableStart <= oneYearFromNow){
|
||||
targetDate = freeAvailableStart;
|
||||
} else{
|
||||
targetDate = episodeAirDate;
|
||||
}
|
||||
}
|
||||
|
||||
return targetDate.Kind == DateTimeKind.Utc ? targetDate : targetDate.ToUniversalTime();
|
||||
}
|
||||
|
||||
public void SetBackgroundImage(){
|
||||
|
|
@ -224,6 +339,7 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
CrunchyrollManager.Instance.InitOptions();
|
||||
|
||||
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
|
||||
await Updater.Instance.CheckGhJsonAsync();
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
|
||||
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
|
||||
|
|
@ -308,4 +424,4 @@ public sealed partial class ProgramManager : ObservableObject{
|
|||
checkForNewEpisodesRunner.Stop();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,27 +5,44 @@ using System.Collections.Specialized;
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.CustomList;
|
||||
using CRD.Utils.QueueManagement;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.ViewModels;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public sealed partial class QueueManager : ObservableObject{
|
||||
|
||||
public static QueueManager Instance{ get; } = new();
|
||||
|
||||
#region Download Variables
|
||||
#region Download Variables
|
||||
|
||||
public RefreshableObservableCollection<CrunchyEpMeta> Queue{ get; } = new();
|
||||
public ObservableCollection<DownloadItemModel> DownloadItemModels{ get; } = new();
|
||||
private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = new();
|
||||
public ReadOnlyObservableCollection<CrunchyEpMeta> Queue{ get; }
|
||||
|
||||
private readonly DownloadItemModelCollection downloadItems = new();
|
||||
|
||||
public ObservableCollection<DownloadItemModel> DownloadItemModels => downloadItems.Items;
|
||||
|
||||
private readonly UiMutationQueue uiMutationQueue;
|
||||
private readonly QueuePersistenceManager queuePersistenceManager;
|
||||
|
||||
private readonly object downloadStartLock = new();
|
||||
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
|
||||
|
||||
private readonly ProcessingSlotManager processingSlots;
|
||||
|
||||
private int pumpScheduled;
|
||||
private int pumpDirty;
|
||||
private DateTimeOffset? autoDownloadBlockedUntilUtc;
|
||||
private readonly object autoDownloadBlockLock = new();
|
||||
|
||||
#endregion
|
||||
|
||||
public int ActiveDownloads{
|
||||
get{
|
||||
lock (downloadStartLock){
|
||||
|
|
@ -34,18 +51,7 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
}
|
||||
}
|
||||
|
||||
private readonly object downloadStartLock = new();
|
||||
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
|
||||
private readonly object processingLock = new();
|
||||
|
||||
private readonly SemaphoreSlim activeProcessingJobs;
|
||||
private int processingJobsLimit;
|
||||
private int borrowed;
|
||||
|
||||
private int pumpScheduled;
|
||||
private int pumpDirty;
|
||||
|
||||
#endregion
|
||||
public bool HasActiveDownloads => ActiveDownloads > 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasFailedItem;
|
||||
|
|
@ -55,18 +61,43 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
private readonly CrunchyrollManager crunchyrollManager;
|
||||
|
||||
public QueueManager(){
|
||||
this.crunchyrollManager = CrunchyrollManager.Instance;
|
||||
crunchyrollManager = CrunchyrollManager.Instance;
|
||||
|
||||
activeProcessingJobs = new SemaphoreSlim(
|
||||
initialCount: crunchyrollManager.CrunOptions.SimultaneousProcessingJobs,
|
||||
maxCount: 2);
|
||||
uiMutationQueue = new UiMutationQueue();
|
||||
queuePersistenceManager = new QueuePersistenceManager(this);
|
||||
Queue = new ReadOnlyObservableCollection<CrunchyEpMeta>(queue);
|
||||
|
||||
processingJobsLimit = crunchyrollManager.CrunOptions.SimultaneousProcessingJobs;
|
||||
processingSlots = new ProcessingSlotManager(
|
||||
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
|
||||
|
||||
Queue.CollectionChanged += UpdateItemListOnRemove;
|
||||
Queue.CollectionChanged += (_, _) => OnQueueStateChanged();
|
||||
queue.CollectionChanged += UpdateItemListOnRemove;
|
||||
queue.CollectionChanged += (_, _) => OnQueueStateChanged();
|
||||
}
|
||||
|
||||
public void AddToQueue(CrunchyEpMeta item){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
if (!queue.Contains(item))
|
||||
queue.Add(item);
|
||||
});
|
||||
}
|
||||
|
||||
public void RemoveFromQueue(CrunchyEpMeta item){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
int index = queue.IndexOf(item);
|
||||
if (index >= 0)
|
||||
queue.RemoveAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
public void ClearQueue(){
|
||||
uiMutationQueue.Enqueue(() => queue.Clear());
|
||||
}
|
||||
|
||||
public void RefreshQueue(){
|
||||
uiMutationQueue.Enqueue(() => queue.Refresh());
|
||||
}
|
||||
|
||||
|
||||
public bool TryStartDownload(DownloadItemModel model){
|
||||
var item = model.epMeta;
|
||||
|
||||
|
|
@ -74,13 +105,36 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
if (activeOrStarting.Contains(item))
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress is{ IsDownloading: true })
|
||||
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress is{ Done: true })
|
||||
if (item.DownloadProgress.IsDone)
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress is{ Error: true })
|
||||
if (item.DownloadProgress.IsError)
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress.IsPaused)
|
||||
return false;
|
||||
|
||||
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
|
||||
return false;
|
||||
|
||||
activeOrStarting.Add(item);
|
||||
}
|
||||
|
||||
NotifyDownloadStateChanged();
|
||||
OnQueueStateChanged();
|
||||
_ = model.StartDownloadCore();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryResumeDownload(CrunchyEpMeta item){
|
||||
lock (downloadStartLock){
|
||||
if (activeOrStarting.Contains(item))
|
||||
return false;
|
||||
|
||||
if (!item.DownloadProgress.IsPaused)
|
||||
return false;
|
||||
|
||||
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
|
||||
|
|
@ -89,8 +143,8 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
activeOrStarting.Add(item);
|
||||
}
|
||||
|
||||
NotifyDownloadStateChanged();
|
||||
OnQueueStateChanged();
|
||||
_ = model.StartDownloadCore();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -102,110 +156,98 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
}
|
||||
|
||||
if (removed){
|
||||
NotifyDownloadStateChanged();
|
||||
OnQueueStateChanged();
|
||||
RequestPump();
|
||||
|
||||
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||
RequestPump();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
|
||||
return activeProcessingJobs.WaitAsync(cancellationToken);
|
||||
return processingSlots.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void ReleaseProcessingSlot(){
|
||||
lock (processingLock){
|
||||
if (borrowed > 0){
|
||||
borrowed--;
|
||||
return;
|
||||
}
|
||||
|
||||
activeProcessingJobs.Release();
|
||||
}
|
||||
processingSlots.Release();
|
||||
}
|
||||
|
||||
public void SetLimit(int newLimit){
|
||||
if (newLimit < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(newLimit));
|
||||
public void SetProcessingLimit(int newLimit){
|
||||
processingSlots.SetLimit(newLimit);
|
||||
}
|
||||
|
||||
lock (processingLock){
|
||||
if (newLimit == processingJobsLimit)
|
||||
return;
|
||||
public void RestorePersistedQueue(){
|
||||
queuePersistenceManager.RestoreQueue();
|
||||
}
|
||||
|
||||
int delta = newLimit - processingJobsLimit;
|
||||
public void SaveQueueSnapshot(){
|
||||
queuePersistenceManager.SaveNow();
|
||||
}
|
||||
|
||||
if (delta > 0){
|
||||
int giveBack = Math.Min(borrowed, delta);
|
||||
borrowed -= giveBack;
|
||||
internal List<CrunchyEpMeta> GetQueueSnapshot(){
|
||||
if (Dispatcher.UIThread.CheckAccess()){
|
||||
return queue.ToList();
|
||||
}
|
||||
|
||||
int toRelease = delta - giveBack;
|
||||
if (toRelease > 0)
|
||||
activeProcessingJobs.Release(toRelease);
|
||||
} else{
|
||||
int toRemove = -delta;
|
||||
return Dispatcher.UIThread
|
||||
.InvokeAsync(() => queue.ToList())
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
||||
|
||||
while (toRemove > 0 && activeProcessingJobs.Wait(0)){
|
||||
toRemove--;
|
||||
}
|
||||
|
||||
|
||||
borrowed += toRemove;
|
||||
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> items){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
queue.Clear();
|
||||
foreach (var item in items){
|
||||
if (!queue.Contains(item))
|
||||
queue.Add(item);
|
||||
}
|
||||
|
||||
processingJobsLimit = newLimit;
|
||||
}
|
||||
RestoreRetryStateFromQueue();
|
||||
UpdateDownloadListItems();
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
|
||||
if (e.Action == NotifyCollectionChangedAction.Remove){
|
||||
if (e.OldItems != null)
|
||||
foreach (var eOldItem in e.OldItems){
|
||||
var downloadItem = DownloadItemModels.FirstOrDefault(downloadItem => downloadItem.epMeta.Equals(eOldItem));
|
||||
if (downloadItem != null){
|
||||
DownloadItemModels.Remove(downloadItem);
|
||||
} else{
|
||||
Console.Error.WriteLine("Failed to Remove Episode from list");
|
||||
}
|
||||
if (e.OldItems != null){
|
||||
foreach (var oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
|
||||
downloadItems.Remove(oldItem);
|
||||
}
|
||||
} else if (e.Action == NotifyCollectionChangedAction.Reset && Queue.Count == 0){
|
||||
DownloadItemModels.Clear();
|
||||
}
|
||||
} else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
|
||||
downloadItems.Clear();
|
||||
}
|
||||
|
||||
UpdateDownloadListItems();
|
||||
}
|
||||
|
||||
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => {
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
if (removeFromQueue){
|
||||
if (Queue.Contains(item))
|
||||
Queue.Remove(item);
|
||||
int index = queue.IndexOf(item);
|
||||
if (index >= 0)
|
||||
queue.RemoveAt(index);
|
||||
} else{
|
||||
Queue.Refresh();
|
||||
queue.Refresh();
|
||||
}
|
||||
|
||||
OnQueueStateChanged();
|
||||
}, Avalonia.Threading.DispatcherPriority.Background);
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateDownloadListItems(){
|
||||
foreach (CrunchyEpMeta crunchyEpMeta in Queue.ToList()){
|
||||
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
|
||||
if (downloadItem != null){
|
||||
downloadItem.Refresh();
|
||||
} else{
|
||||
downloadItem = new DownloadItemModel(crunchyEpMeta);
|
||||
_ = downloadItem.LoadImage();
|
||||
DownloadItemModels.Add(downloadItem);
|
||||
}
|
||||
}
|
||||
downloadItems.SyncFromQueue(queue);
|
||||
|
||||
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
|
||||
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
|
||||
|
||||
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||
RequestPump();
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestPump(){
|
||||
private void RequestPump(){
|
||||
Interlocked.Exchange(ref pumpDirty, 1);
|
||||
|
||||
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
|
||||
|
|
@ -223,7 +265,7 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
}
|
||||
} finally{
|
||||
Interlocked.Exchange(ref pumpScheduled, 0);
|
||||
|
||||
|
||||
if (Volatile.Read(ref pumpDirty) == 1 &&
|
||||
Interlocked.CompareExchange(ref pumpScheduled, 1, 0) == 0){
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
|
|
@ -234,8 +276,27 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
}
|
||||
|
||||
private void PumpQueue(){
|
||||
List<CrunchyEpMeta> toStart = new();
|
||||
if (!crunchyrollManager.CrunOptions.AutoDownload)
|
||||
return;
|
||||
|
||||
lock (autoDownloadBlockLock){
|
||||
if (autoDownloadBlockedUntilUtc.HasValue && !HasPendingRetryItems()){
|
||||
autoDownloadBlockedUntilUtc = null;
|
||||
}
|
||||
|
||||
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value > DateTimeOffset.UtcNow){
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoDownloadBlockedUntilUtc.HasValue){
|
||||
autoDownloadBlockedUntilUtc = null;
|
||||
}
|
||||
}
|
||||
|
||||
List<CrunchyEpMeta> toStart = new();
|
||||
List<CrunchyEpMeta> toResume = new();
|
||||
bool changed = false;
|
||||
|
||||
lock (downloadStartLock){
|
||||
int limit = crunchyrollManager.CrunOptions.SimultaneousDownloads;
|
||||
int freeSlots = Math.Max(0, limit - activeOrStarting.Count);
|
||||
|
|
@ -243,17 +304,20 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
if (freeSlots == 0)
|
||||
return;
|
||||
|
||||
foreach (var item in Queue.ToList()){
|
||||
foreach (var item in queue.ToList()){
|
||||
if (freeSlots == 0)
|
||||
break;
|
||||
|
||||
if (item.DownloadProgress.Error)
|
||||
if (item.DownloadProgress.IsError)
|
||||
continue;
|
||||
|
||||
if (item.DownloadProgress.Done)
|
||||
if (item.DownloadProgress.IsWaitingForRetry)
|
||||
continue;
|
||||
|
||||
if (item.DownloadProgress.IsDownloading)
|
||||
if (item.DownloadProgress.IsDone)
|
||||
continue;
|
||||
|
||||
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||
continue;
|
||||
|
||||
if (activeOrStarting.Contains(item))
|
||||
|
|
@ -261,12 +325,29 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
|
||||
activeOrStarting.Add(item);
|
||||
freeSlots--;
|
||||
toStart.Add(item);
|
||||
|
||||
if (item.DownloadProgress.IsPaused){
|
||||
toResume.Add(item);
|
||||
} else{
|
||||
toStart.Add(item);
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed){
|
||||
NotifyDownloadStateChanged();
|
||||
}
|
||||
|
||||
foreach (var item in toResume){
|
||||
item.DownloadProgress.State = item.DownloadProgress.ResumeState;
|
||||
var model = downloadItems.Find(item);
|
||||
model?.Refresh();
|
||||
}
|
||||
|
||||
foreach (var item in toStart){
|
||||
var model = DownloadItemModels.FirstOrDefault(x => x.epMeta.Equals(item));
|
||||
var model = downloadItems.Find(item);
|
||||
if (model != null){
|
||||
_ = model.StartDownloadCore();
|
||||
} else{
|
||||
|
|
@ -277,431 +358,110 @@ public sealed partial class QueueManager : ObservableObject{
|
|||
OnQueueStateChanged();
|
||||
}
|
||||
|
||||
public void BlockAutoDownloadUntil(TimeSpan delay, CancellationToken cancellationToken = default){
|
||||
DateTimeOffset unblockAt = DateTimeOffset.UtcNow.Add(delay);
|
||||
|
||||
lock (autoDownloadBlockLock){
|
||||
if (!autoDownloadBlockedUntilUtc.HasValue || unblockAt > autoDownloadBlockedUntilUtc.Value){
|
||||
autoDownloadBlockedUntilUtc = unblockAt;
|
||||
} else{
|
||||
unblockAt = autoDownloadBlockedUntilUtc.Value;
|
||||
}
|
||||
}
|
||||
|
||||
_ = Task.Run(async () => {
|
||||
try{
|
||||
var remaining = unblockAt - DateTimeOffset.UtcNow;
|
||||
if (remaining > TimeSpan.Zero){
|
||||
await Task.Delay(remaining, cancellationToken);
|
||||
}
|
||||
|
||||
lock (autoDownloadBlockLock){
|
||||
if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value <= DateTimeOffset.UtcNow){
|
||||
autoDownloadBlockedUntilUtc = null;
|
||||
}
|
||||
}
|
||||
|
||||
RefreshQueue();
|
||||
UpdateDownloadListItems();
|
||||
} catch (OperationCanceledException){
|
||||
// ignored
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public void ScheduleRetry(CrunchyEpMeta item, TimeSpan delay, string statusText, CancellationToken cancellationToken = default){
|
||||
item.DownloadProgress.ScheduleRetry(delay, statusText);
|
||||
RefreshQueue();
|
||||
OnQueueStateChanged();
|
||||
|
||||
ScheduleRetryWake(item, item.DownloadProgress.RetryAtUtc, cancellationToken);
|
||||
}
|
||||
|
||||
private void RestoreRetryStateFromQueue(){
|
||||
var retryItems = queue
|
||||
.Where(item => item.DownloadProgress.IsWaitingForRetry)
|
||||
.ToList();
|
||||
|
||||
if (retryItems.Count == 0){
|
||||
lock (autoDownloadBlockLock){
|
||||
autoDownloadBlockedUntilUtc = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var maxRetryAt = retryItems
|
||||
.Select(item => item.DownloadProgress.RetryAtUtc)
|
||||
.OfType<DateTimeOffset>()
|
||||
.Max();
|
||||
|
||||
lock (autoDownloadBlockLock){
|
||||
autoDownloadBlockedUntilUtc = maxRetryAt;
|
||||
}
|
||||
|
||||
foreach (var retryItem in retryItems){
|
||||
ScheduleRetryWake(retryItem, retryItem.DownloadProgress.RetryAtUtc);
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasPendingRetryItems(){
|
||||
return queue.Any(item => item.DownloadProgress.IsWaitingForRetry);
|
||||
}
|
||||
|
||||
private void ScheduleRetryWake(CrunchyEpMeta item, DateTimeOffset? retryAtUtc, CancellationToken cancellationToken = default){
|
||||
if (!retryAtUtc.HasValue){
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () => {
|
||||
try{
|
||||
var remaining = retryAtUtc.Value - DateTimeOffset.UtcNow;
|
||||
if (remaining > TimeSpan.Zero){
|
||||
await Task.Delay(remaining, cancellationToken);
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested){
|
||||
return;
|
||||
}
|
||||
|
||||
item.DownloadProgress.RetryAtUtc = null;
|
||||
RefreshQueue();
|
||||
UpdateDownloadListItems();
|
||||
} catch (OperationCanceledException){
|
||||
// ignored
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
private void OnQueueStateChanged(){
|
||||
QueueStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
public async Task CrAddEpisodeToQueue(string epId, string crLocale, List<string> dubLang, bool updateHistory = false, EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default){
|
||||
if (string.IsNullOrEmpty(epId)){
|
||||
return;
|
||||
}
|
||||
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
||||
|
||||
var episodeL = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(epId, crLocale);
|
||||
|
||||
|
||||
if (episodeL != null){
|
||||
if (episodeL.IsPremiumOnly && !CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.HasPremium){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode – make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
|
||||
return;
|
||||
}
|
||||
|
||||
var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episodeL, updateHistory);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
var variant = sList.EpisodeAndLanguages.Variants.First();
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
|
||||
if (historyEpisode.dublist.Count > 0){
|
||||
dubLang = historyEpisode.dublist;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var selected = CrunchyrollManager.Instance.CrEpisode.EpisodeMeta(sList, dubLang);
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
|
||||
if (selected.Data is{ Count: > 0 }){
|
||||
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(selected.Data.First().MediaId,
|
||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
|
||||
selected.Description = episode?.Description ?? selected.Description;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.Data is{ Count: > 0 }){
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
// var historyEpisode = CrHistory.GetHistoryEpisodeWithDownloadDir(selected.ShowId, selected.SeasonId, selected.Data.First().MediaId);
|
||||
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
|
||||
if (historyEpisode.historyEpisode != null){
|
||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
|
||||
selected.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
|
||||
selected.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
|
||||
selected.DownloadPath = historyEpisode.downloadDirPath;
|
||||
}
|
||||
}
|
||||
|
||||
selected.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
selected.DownloadSubs = historyEpisode.sublist.Count > 0 ? historyEpisode.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
||||
|
||||
selected.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && selected.Data.Count > 1){
|
||||
var sortedMetaData = selected.Data
|
||||
.OrderBy(metaData => {
|
||||
var locale = metaData.Lang?.CrLocale ?? string.Empty;
|
||||
var index = dubLang.IndexOf(locale);
|
||||
return index != -1 ? index : int.MaxValue;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (sortedMetaData.Count != 0){
|
||||
var first = sortedMetaData.First();
|
||||
selected.Data = [first];
|
||||
selected.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
|
||||
}
|
||||
}
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
|
||||
if (newOptions == null){
|
||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (episodeDownloadMode){
|
||||
case EpisodeDownloadMode.OnlyVideo:
|
||||
newOptions.Novids = false;
|
||||
newOptions.Noaudio = true;
|
||||
selected.DownloadSubs = ["none"];
|
||||
break;
|
||||
case EpisodeDownloadMode.OnlyAudio:
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = false;
|
||||
selected.DownloadSubs = ["none"];
|
||||
break;
|
||||
case EpisodeDownloadMode.OnlySubs:
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = true;
|
||||
break;
|
||||
case EpisodeDownloadMode.Default:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
|
||||
if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
|
||||
selected.HighlightAllAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
||||
if (!selected.DownloadSubs.Contains("none") && !selected.DownloadSubs.Contains("all") && !selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
|
||||
//missing subs
|
||||
Console.Error.WriteLine($"Episode not added because of missing subs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
||||
//missing dubs
|
||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newOptions.DubLang = dubLang;
|
||||
|
||||
selected.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(selected);
|
||||
|
||||
|
||||
if (selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
||||
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
|
||||
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
|
||||
|
||||
var languages = sList.EpisodeAndLanguages.Variants
|
||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
|
||||
.ToArray();
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
|
||||
} else{
|
||||
Console.WriteLine("Added Episode to Queue");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
|
||||
}
|
||||
} else{
|
||||
Console.WriteLine("Episode couldn't be added to Queue");
|
||||
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
|
||||
|
||||
var languages = sList.EpisodeAndLanguages.Variants
|
||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
|
||||
.ToArray();
|
||||
|
||||
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ?? [])}]");
|
||||
if (!CrunchyrollManager.Instance.CrunOptions.DownloadOnlyWithAllSelectedDubSub){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("Couldn't find episode trying to find movie with id");
|
||||
|
||||
var movie = await CrunchyrollManager.Instance.CrMovies.ParseMovieById(epId, crLocale);
|
||||
|
||||
if (movie != null){
|
||||
var movieMeta = CrunchyrollManager.Instance.CrMovies.EpisodeMeta(movie, dubLang);
|
||||
|
||||
if (movieMeta != null){
|
||||
movieMeta.DownloadSubs = CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
||||
movieMeta.OnlySubs = episodeDownloadMode == EpisodeDownloadMode.OnlySubs;
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
|
||||
if (newOptions == null){
|
||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (episodeDownloadMode){
|
||||
case EpisodeDownloadMode.OnlyVideo:
|
||||
newOptions.Novids = false;
|
||||
newOptions.Noaudio = true;
|
||||
movieMeta.DownloadSubs = ["none"];
|
||||
break;
|
||||
case EpisodeDownloadMode.OnlyAudio:
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = false;
|
||||
movieMeta.DownloadSubs = ["none"];
|
||||
break;
|
||||
case EpisodeDownloadMode.OnlySubs:
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = true;
|
||||
break;
|
||||
case EpisodeDownloadMode.Default:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
newOptions.DubLang = dubLang;
|
||||
|
||||
movieMeta.DownloadSettings = newOptions;
|
||||
|
||||
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
||||
if (!movieMeta.DownloadSubs.Contains("none") && !movieMeta.DownloadSubs.Contains("all") && !movieMeta.DownloadSubs.All(item => (movieMeta.AvailableSubs ?? []).Contains(item))){
|
||||
//missing subs
|
||||
Console.Error.WriteLine($"Episode not added because of missing subs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (movieMeta.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
||||
//missing dubs
|
||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Queue.Add(movieMeta);
|
||||
|
||||
Console.WriteLine("Added Movie to Queue");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added Movie to Queue", ToastType.Information, 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"No episode or movie found with the id: {epId}");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue - No episode or movie found with the id: {epId}", ToastType.Error, 3));
|
||||
|
||||
private void NotifyDownloadStateChanged(){
|
||||
OnPropertyChanged(nameof(ActiveDownloads));
|
||||
OnPropertyChanged(nameof(HasActiveDownloads));
|
||||
}
|
||||
|
||||
|
||||
public void CrAddMusicMetaToQueue(CrunchyEpMeta epMeta){
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
epMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(epMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
|
||||
}
|
||||
|
||||
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
||||
|
||||
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
|
||||
|
||||
if (musicVideo != null){
|
||||
var musicVideoMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(musicVideo);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
|
||||
}
|
||||
|
||||
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
||||
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
musicVideoMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(musicVideoMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added music video to the queue", ToastType.Information, 1));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
||||
|
||||
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
|
||||
|
||||
if (concert != null){
|
||||
var concertMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(concert);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
|
||||
}
|
||||
|
||||
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
||||
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
concertMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(concertMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added concert to the queue", ToastType.Information, 1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
|
||||
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
|
||||
|
||||
var failed = false;
|
||||
var partialAdd = false;
|
||||
|
||||
|
||||
foreach (var crunchyEpMeta in selected.Values.ToList()){
|
||||
if (crunchyEpMeta.Data.FirstOrDefault() != null){
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
var historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDownloadDir(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId, crunchyEpMeta.Data.First().MediaId);
|
||||
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
|
||||
if (historyEpisode.historyEpisode != null){
|
||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrEpisodeNumber)){
|
||||
crunchyEpMeta.EpisodeNumber = historyEpisode.historyEpisode.SonarrEpisodeNumber;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(historyEpisode.historyEpisode.SonarrSeasonNumber)){
|
||||
crunchyEpMeta.Season = historyEpisode.historyEpisode.SonarrSeasonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(historyEpisode.downloadDirPath)){
|
||||
crunchyEpMeta.DownloadPath = historyEpisode.downloadDirPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.IncludeVideoDescription){
|
||||
if (crunchyEpMeta.Data is{ Count: > 0 }){
|
||||
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(crunchyEpMeta.Data.First().MediaId,
|
||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DescriptionLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.DescriptionLang, true);
|
||||
crunchyEpMeta.Description = episode?.Description ?? crunchyEpMeta.Description;
|
||||
}
|
||||
}
|
||||
|
||||
var subLangList = CrunchyrollManager.Instance.History.GetSubList(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId);
|
||||
|
||||
crunchyEpMeta.VideoQuality = !string.IsNullOrEmpty(subLangList.videoQuality) ? subLangList.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
crunchyEpMeta.DownloadSubs = subLangList.sublist.Count > 0 ? subLangList.sublist : CrunchyrollManager.Instance.CrunOptions.DlSubs;
|
||||
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub && crunchyEpMeta.Data.Count > 1){
|
||||
var sortedMetaData = crunchyEpMeta.Data
|
||||
.OrderBy(metaData => {
|
||||
var locale = metaData.Lang?.CrLocale ?? string.Empty;
|
||||
var index = data.DubLang.IndexOf(locale);
|
||||
return index != -1 ? index : int.MaxValue;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (sortedMetaData.Count != 0){
|
||||
var first = sortedMetaData.First();
|
||||
crunchyEpMeta.Data = [first];
|
||||
crunchyEpMeta.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
|
||||
}
|
||||
}
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
|
||||
if (newOptions == null){
|
||||
Console.Error.WriteLine("Failed to create a copy of your current settings");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue, check the logs", ToastType.Error, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (crunchyEpMeta.OnlySubs){
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = true;
|
||||
}
|
||||
|
||||
newOptions.DubLang = data.DubLang;
|
||||
|
||||
crunchyEpMeta.DownloadSettings = newOptions;
|
||||
|
||||
if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
|
||||
if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
|
||||
crunchyEpMeta.HighlightAllAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
|
||||
if (!crunchyEpMeta.DownloadSubs.Contains("none") && !crunchyEpMeta.DownloadSubs.Contains("all") && !crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
|
||||
//missing subs
|
||||
Console.Error.WriteLine($"Episode not added because of missing subs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
||||
//missing dubs
|
||||
Console.Error.WriteLine($"Episode not added because of missing dubs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Queue.Add(crunchyEpMeta);
|
||||
|
||||
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
|
||||
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
|
||||
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
|
||||
|
||||
partialAdd = true;
|
||||
|
||||
var languages = (crunchyEpMeta.Data.First().Versions ?? []).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray();
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"{crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", crunchyEpMeta.AvailableSubs ?? [])}]");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
|
||||
}
|
||||
} else{
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed && !partialAdd){
|
||||
MainWindow.Instance.ShowError("Not all episodes could be added – make sure that you are signed in with an account that has an active premium subscription?");
|
||||
} else if (selected.Values.Count > 0 && !partialAdd){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1));
|
||||
} else if (!partialAdd){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,178 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using NetCoreAudio;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class AudioPlayer{
|
||||
private readonly Player _player;
|
||||
private bool _isPlaying = false;
|
||||
private bool _isPlaying;
|
||||
private WaveOutEvent? _waveOut;
|
||||
private AudioFileReader? _audioFileReader;
|
||||
private TaskCompletionSource? _playbackCompleted;
|
||||
|
||||
public AudioPlayer(){
|
||||
_player = new Player();
|
||||
}
|
||||
|
||||
public async void Play(string path){
|
||||
public static (bool IsValid, string ErrorMessage) ValidateSoundFile(string path){
|
||||
if (string.IsNullOrWhiteSpace(path)){
|
||||
return (false, "The selected sound file path is empty.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path)){
|
||||
return (false, "The selected sound file does not exist.");
|
||||
}
|
||||
|
||||
try{
|
||||
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
if (stream.Length == 0){
|
||||
return (false, "The selected sound file is empty.");
|
||||
}
|
||||
} catch (Exception exception){
|
||||
return (false, $"The selected sound file could not be opened: {exception.Message}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Path.GetExtension(path))){
|
||||
return (false, "The selected sound file has no file extension.");
|
||||
}
|
||||
|
||||
return (true, string.Empty);
|
||||
}
|
||||
|
||||
public async Task<(bool IsSuccess, string ErrorMessage)> ValidatePlaybackAsync(string path){
|
||||
var fileValidation = ValidateSoundFile(path);
|
||||
if (!fileValidation.IsValid){
|
||||
return (false, fileValidation.ErrorMessage);
|
||||
}
|
||||
|
||||
if (_isPlaying){
|
||||
return (false, "Audio playback is already in progress.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows()){
|
||||
try{
|
||||
_isPlaying = true;
|
||||
DisposeWindowsPlayback();
|
||||
|
||||
_audioFileReader = new AudioFileReader(path);
|
||||
_waveOut = new WaveOutEvent();
|
||||
_playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_waveOut.PlaybackStopped += (_, args) => {
|
||||
if (args.Exception != null){
|
||||
_playbackCompleted?.TrySetException(args.Exception);
|
||||
} else{
|
||||
_playbackCompleted?.TrySetResult();
|
||||
}
|
||||
};
|
||||
|
||||
_waveOut.Init(_audioFileReader);
|
||||
_waveOut.Play();
|
||||
await _playbackCompleted.Task;
|
||||
return (true, string.Empty);
|
||||
} catch (Exception exception){
|
||||
return (false, exception.Message);
|
||||
} finally{
|
||||
DisposeWindowsPlayback();
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
try{
|
||||
_isPlaying = true;
|
||||
await _player.Play(path);
|
||||
return (true, string.Empty);
|
||||
} catch (Exception exception){
|
||||
return (false, exception.Message);
|
||||
} finally{
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PlayAsync(string path){
|
||||
var fileValidation = ValidateSoundFile(path);
|
||||
if (!fileValidation.IsValid){
|
||||
Console.Error.WriteLine($"Failed to play audio '{path}': {fileValidation.ErrorMessage}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isPlaying){
|
||||
Console.WriteLine("Audio is already playing, ignoring duplicate request.");
|
||||
return;
|
||||
}
|
||||
|
||||
_isPlaying = true;
|
||||
await _player.Play(path);
|
||||
_isPlaying = false;
|
||||
if (OperatingSystem.IsWindows()){
|
||||
try{
|
||||
_isPlaying = true;
|
||||
DisposeWindowsPlayback();
|
||||
|
||||
_audioFileReader = new AudioFileReader(path);
|
||||
_waveOut = new WaveOutEvent();
|
||||
_playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
_waveOut.PlaybackStopped += (_, args) => {
|
||||
if (args.Exception != null){
|
||||
_playbackCompleted?.TrySetException(args.Exception);
|
||||
} else{
|
||||
_playbackCompleted?.TrySetResult();
|
||||
}
|
||||
};
|
||||
|
||||
_waveOut.Init(_audioFileReader);
|
||||
_waveOut.Play();
|
||||
|
||||
await _playbackCompleted.Task;
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine($"Failed to play audio '{path}': {exception}");
|
||||
} finally{
|
||||
DisposeWindowsPlayback();
|
||||
_isPlaying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try{
|
||||
_isPlaying = true;
|
||||
await _player.Play(path);
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine($"Failed to play audio '{path}': {exception.Message}");
|
||||
} finally{
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async void Stop(){
|
||||
await _player.Stop();
|
||||
_isPlaying = false;
|
||||
public async Task StopAsync(){
|
||||
if (OperatingSystem.IsWindows()){
|
||||
try{
|
||||
_waveOut?.Stop();
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine($"Failed to stop audio playback: {exception}");
|
||||
} finally{
|
||||
DisposeWindowsPlayback();
|
||||
_isPlaying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try{
|
||||
await _player.Stop();
|
||||
} catch (Exception exception){
|
||||
Console.Error.WriteLine($"Failed to stop audio playback: {exception}");
|
||||
} finally{
|
||||
_isPlaying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeWindowsPlayback(){
|
||||
_playbackCompleted = null;
|
||||
|
||||
_waveOut?.Dispose();
|
||||
_waveOut = null;
|
||||
|
||||
_audioFileReader?.Dispose();
|
||||
_audioFileReader = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public class Widevine{
|
|||
private byte[] privateKey = new byte[0];
|
||||
private byte[] identifierBlob = new byte[0];
|
||||
|
||||
public bool canDecrypt = false;
|
||||
public bool canDecrypt;
|
||||
|
||||
|
||||
#region Singelton
|
||||
|
|
@ -114,7 +114,7 @@ public class Widevine{
|
|||
|
||||
// var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
|
||||
|
||||
var response = (IsOk: false, ResponseContent: "", error: "");
|
||||
var response = (IsOk: false, ResponseContent: "", error: "",Headers: new Dictionary<string, string>());
|
||||
for (var attempt = 0; attempt < 3 + 1; attempt++){
|
||||
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
|
||||
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ public enum SeriesType{
|
|||
Artist,
|
||||
[EnumMember(Value = "Series")]
|
||||
Series,
|
||||
[EnumMember(Value = "Movie")]
|
||||
Movie,
|
||||
[EnumMember(Value = "Unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
|
@ -276,6 +278,15 @@ public enum EpisodeDownloadMode{
|
|||
OnlySubs,
|
||||
}
|
||||
|
||||
public enum DownloadState{
|
||||
Queued,
|
||||
Downloading,
|
||||
Paused,
|
||||
Processing,
|
||||
Done,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum HistoryRefreshMode{
|
||||
DefaultAll = 0,
|
||||
DefaultActive = 1,
|
||||
|
|
@ -303,4 +314,4 @@ public enum SonarrStatus{
|
|||
Upcoming,
|
||||
Ended,
|
||||
Deleted
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ public class CfgManager{
|
|||
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
|
||||
|
||||
public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json");
|
||||
public static readonly string PathCrQueue = Path.Combine(workingDirectory, "config", "queue.json");
|
||||
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
|
||||
|
||||
private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
|
||||
|
|
@ -44,7 +45,7 @@ public class CfgManager{
|
|||
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
|
||||
|
||||
private static StreamWriter logFile;
|
||||
private static bool isLogModeEnabled = false;
|
||||
private static bool isLogModeEnabled;
|
||||
|
||||
static CfgManager(){
|
||||
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
|
||||
|
|
@ -366,4 +367,14 @@ public class CfgManager{
|
|||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void DeleteFileIfExists(string pathToFile){
|
||||
try{
|
||||
if (File.Exists(pathToFile)){
|
||||
File.Delete(pathToFile);
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ using Newtonsoft.Json;
|
|||
namespace CRD.Utils.HLS;
|
||||
|
||||
public class HlsDownloader{
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private Data _data = new();
|
||||
|
||||
private CrunchyEpMeta _currentEpMeta;
|
||||
|
|
@ -24,12 +25,24 @@ public class HlsDownloader{
|
|||
private bool _isAudio;
|
||||
private bool _newDownloadMethode;
|
||||
|
||||
private async Task WaitWhilePausedAsync(CancellationToken cancellationToken){
|
||||
while (_currentEpMeta.DownloadProgress.IsPaused){
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
}
|
||||
|
||||
await Task.Delay(500, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio, bool newDownloadMethode){
|
||||
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
|
||||
throw new Exception("Playlist is empty");
|
||||
}
|
||||
|
||||
_currentEpMeta = meta;
|
||||
_cancellationToken = meta.Cts.Token;
|
||||
|
||||
_isVideo = isVideo;
|
||||
_isAudio = isAudio;
|
||||
|
|
@ -62,6 +75,7 @@ public class HlsDownloader{
|
|||
|
||||
|
||||
public async Task<(bool Ok, PartsData Parts)> Download(){
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
string fn = _data.OutputFile ?? string.Empty;
|
||||
|
||||
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
|
||||
|
|
@ -141,8 +155,8 @@ public class HlsDownloader{
|
|||
|
||||
try{
|
||||
var initDl = await DownloadPart(initSeg, 0, 0);
|
||||
await File.WriteAllBytesAsync(fn, initDl);
|
||||
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }));
|
||||
await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
|
||||
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }), _cancellationToken);
|
||||
Console.WriteLine("Init part downloaded.");
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
|
||||
|
|
@ -185,7 +199,7 @@ public class HlsDownloader{
|
|||
}
|
||||
|
||||
try{
|
||||
await Task.WhenAll(keyTasks.Values);
|
||||
await Task.WhenAll(keyTasks.Values).WaitAsync(_cancellationToken);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
|
||||
throw;
|
||||
|
|
@ -200,7 +214,7 @@ public class HlsDownloader{
|
|||
}
|
||||
|
||||
while (partTasks.Count > 0){
|
||||
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values);
|
||||
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values).WaitAsync(_cancellationToken);
|
||||
int completedIndex = -1;
|
||||
foreach (var task in partTasks){
|
||||
if (task.Value == completedTask){
|
||||
|
|
@ -234,7 +248,7 @@ public class HlsDownloader{
|
|||
while (attempt < 3 && !writeSuccess){
|
||||
try{
|
||||
using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
||||
await stream.WriteAsync(part, 0, part.Length);
|
||||
await stream.WriteAsync(part, 0, part.Length, _cancellationToken);
|
||||
}
|
||||
|
||||
writeSuccess = true;
|
||||
|
|
@ -242,7 +256,7 @@ public class HlsDownloader{
|
|||
Console.Error.WriteLine(ex);
|
||||
Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)");
|
||||
Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying");
|
||||
await Task.Delay(_data.WaitTime);
|
||||
await Task.Delay(_data.WaitTime, _cancellationToken);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
|
@ -267,12 +281,17 @@ public class HlsDownloader{
|
|||
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
|
||||
|
||||
await WaitWhilePausedAsync(_cancellationToken);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
|
||||
// Log progress
|
||||
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time)} | {downloadSpeed})");
|
||||
|
||||
_currentEpMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Downloading,
|
||||
Percent = dataLog.Percent,
|
||||
Time = dataLog.Time,
|
||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||
|
|
@ -280,7 +299,7 @@ public class HlsDownloader{
|
|||
};
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
if (!_currentEpMeta.DownloadProgress.Done){
|
||||
if (!_currentEpMeta.DownloadProgress.IsDone){
|
||||
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
|
||||
try{
|
||||
if (File.Exists(downloadItemDownloadedFile)){
|
||||
|
|
@ -295,13 +314,11 @@ public class HlsDownloader{
|
|||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
while (_currentEpMeta.Paused){
|
||||
await Task.Delay(500);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
await WaitWhilePausedAsync(_cancellationToken);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -311,10 +328,110 @@ public class HlsDownloader{
|
|||
|
||||
private static readonly object _resumeLock = new object();
|
||||
|
||||
private void CleanupDownloadedFiles(){
|
||||
if (_currentEpMeta.DownloadProgress.IsDone){
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in _currentEpMeta.downloadedFiles){
|
||||
try{
|
||||
if (File.Exists(file)){
|
||||
File.Delete(file);
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadBufferedSegmentAsync(int index, List<dynamic> segments, string tempDir, string resumeFile, int totalSeg, int mergedParts, SemaphoreSlim semaphore,
|
||||
CancellationTokenSource cancellationSource, CancellationToken token, Action markError, Func<long> getLastUiUpdate, Action<long> setLastUiUpdate){
|
||||
try{
|
||||
token.ThrowIfCancellationRequested();
|
||||
await WaitWhilePausedAsync(token);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cancellationSource.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
var segment = new Segment{
|
||||
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
|
||||
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
|
||||
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
|
||||
};
|
||||
|
||||
var data = await DownloadPart(segment, index, _data.Offset);
|
||||
|
||||
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
|
||||
await File.WriteAllBytesAsync(tempFile, data, token);
|
||||
|
||||
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
|
||||
lock (_resumeLock){
|
||||
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
|
||||
DownloadedParts = currentDownloaded,
|
||||
MergedParts = mergedParts,
|
||||
Total = totalSeg
|
||||
}));
|
||||
}
|
||||
|
||||
long lastUiUpdate = getLastUiUpdate();
|
||||
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - lastUiUpdate > 500){
|
||||
var dataLog = GetDownloadInfo(
|
||||
lastUiUpdate,
|
||||
currentDownloaded,
|
||||
totalSeg,
|
||||
_data.BytesDownloaded,
|
||||
_data.TotalBytes
|
||||
);
|
||||
|
||||
_data.BytesDownloaded = 0;
|
||||
setLastUiUpdate(DateTimeOffset.Now.ToUnixTimeMilliseconds());
|
||||
|
||||
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
|
||||
await WaitWhilePausedAsync(token);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cancellationSource.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
|
||||
|
||||
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
||||
State = DownloadState.Downloading,
|
||||
Percent = dataLog.Percent,
|
||||
Time = dataLog.Time,
|
||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
|
||||
};
|
||||
}
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cancellationSource.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
await WaitWhilePausedAsync(token);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cancellationSource.Cancel();
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
|
||||
markError();
|
||||
cancellationSource.Cancel();
|
||||
} finally{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Ok, PartsData Parts)> DownloadSegmentsBufferedResumeAsync(List<dynamic> segments, string fn){
|
||||
var totalSeg = _data.Parts.Total;
|
||||
string sessionId = Path.GetFileNameWithoutExtension(fn);
|
||||
string tempDir = Path.Combine(Path.GetDirectoryName(fn), $"{sessionId}_temp");
|
||||
string tempDir = Path.Combine(Path.GetDirectoryName(fn) ?? string.Empty, $"{sessionId}_temp");
|
||||
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
|
|
@ -328,6 +445,7 @@ public class HlsDownloader{
|
|||
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
|
||||
mergedParts = (int?)resumeData?.MergedParts ?? 0;
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -336,133 +454,76 @@ public class HlsDownloader{
|
|||
|
||||
var semaphore = new SemaphoreSlim(_data.Threads);
|
||||
var downloadTasks = new List<Task>();
|
||||
bool errorOccurred = false;
|
||||
int errorOccurred = 0;
|
||||
|
||||
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
|
||||
var token = cts.Token;
|
||||
|
||||
for (int i = 0; i < segments.Count; i++){
|
||||
try{
|
||||
await semaphore.WaitAsync(token);
|
||||
} catch (OperationCanceledException){
|
||||
break;
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(tempDir, $"part_{i:D6}.tmp"))){
|
||||
semaphore.Release();
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = i;
|
||||
|
||||
|
||||
downloadTasks.Add(Task.Run(async () => {
|
||||
try{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var segment = new Segment{
|
||||
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
|
||||
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
|
||||
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
|
||||
};
|
||||
|
||||
var data = await DownloadPart(segment, index, _data.Offset);
|
||||
|
||||
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
|
||||
await File.WriteAllBytesAsync(tempFile, data);
|
||||
|
||||
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
|
||||
lock (_resumeLock){
|
||||
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
|
||||
DownloadedParts = currentDownloaded,
|
||||
MergedParts = mergedParts,
|
||||
Total = totalSeg
|
||||
}));
|
||||
}
|
||||
|
||||
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - _lastUiUpdate > 500){
|
||||
var dataLog = GetDownloadInfo(
|
||||
_lastUiUpdate,
|
||||
currentDownloaded,
|
||||
totalSeg,
|
||||
_data.BytesDownloaded,
|
||||
_data.TotalBytes
|
||||
);
|
||||
|
||||
_data.BytesDownloaded = 0;
|
||||
_lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
|
||||
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
|
||||
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
|
||||
|
||||
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
||||
IsDownloading = true,
|
||||
Percent = dataLog.Percent,
|
||||
Time = dataLog.Time,
|
||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
|
||||
};
|
||||
}
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cts.Cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
|
||||
while (_currentEpMeta.Paused){
|
||||
await Task.Delay(500);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
cts.Cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
|
||||
errorOccurred = true;
|
||||
cts.Cancel();
|
||||
} finally{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, token));
|
||||
void CleanupBufferedArtifacts(bool cleanAll = true){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, cleanAll);
|
||||
}
|
||||
|
||||
try{
|
||||
await Task.WhenAll(downloadTasks);
|
||||
} catch (OperationCanceledException){
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
if (!_currentEpMeta.DownloadProgress.Done){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
||||
for (int i = 0; i < segments.Count; i++){
|
||||
try{
|
||||
await semaphore.WaitAsync(token);
|
||||
} catch (OperationCanceledException){
|
||||
break;
|
||||
}
|
||||
} else{
|
||||
Console.Error.WriteLine("Download cancelled due to error.");
|
||||
|
||||
if (File.Exists(Path.Combine(tempDir, $"part_{i:D6}.tmp"))){
|
||||
semaphore.Release();
|
||||
continue;
|
||||
}
|
||||
|
||||
int index = i;
|
||||
|
||||
|
||||
downloadTasks.Add(DownloadBufferedSegmentAsync(index, segments, tempDir, resumeFile, totalSeg, mergedParts, semaphore, cts, token,
|
||||
() => Interlocked.Exchange(ref errorOccurred, 1),
|
||||
() => Volatile.Read(ref _lastUiUpdate),
|
||||
value => Interlocked.Exchange(ref _lastUiUpdate, value)));
|
||||
}
|
||||
|
||||
try{
|
||||
await Task.WhenAll(downloadTasks);
|
||||
} catch (OperationCanceledException){
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
CleanupBufferedArtifacts();
|
||||
} else{
|
||||
Console.Error.WriteLine("Download cancelled due to error.");
|
||||
CleanupBufferedArtifacts(false);
|
||||
}
|
||||
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
} finally{
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref errorOccurred) == 1){
|
||||
CleanupBufferedArtifacts(false);
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
|
||||
if (errorOccurred)
|
||||
return (false, _data.Parts);
|
||||
|
||||
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
||||
for (int i = mergedParts; i < segments.Count; i++){
|
||||
if (token.IsCancellationRequested)
|
||||
if (token.IsCancellationRequested){
|
||||
CleanupBufferedArtifacts();
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
|
||||
string tempFile = Path.Combine(tempDir, $"part_{i:D6}.tmp");
|
||||
if (!File.Exists(tempFile)){
|
||||
Console.Error.WriteLine($"Missing temp file for part {i}, aborting merge.");
|
||||
CleanupBufferedArtifacts(false);
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
|
||||
byte[] data = await File.ReadAllBytesAsync(tempFile);
|
||||
await output.WriteAsync(data, 0, data.Length);
|
||||
byte[] data = await File.ReadAllBytesAsync(tempFile, token);
|
||||
await output.WriteAsync(data, 0, data.Length, token);
|
||||
|
||||
mergedParts++;
|
||||
|
||||
|
|
@ -475,21 +536,24 @@ public class HlsDownloader{
|
|||
var dataLog = GetDownloadInfo(_data.DateStart, mergedParts, totalSeg, _data.BytesDownloaded, _data.TotalBytes);
|
||||
Console.WriteLine($"{mergedParts}/{totalSeg} parts merged [{dataLog.Percent}%]");
|
||||
|
||||
await WaitWhilePausedAsync(token);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
CleanupBufferedArtifacts();
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
|
||||
_currentEpMeta.DownloadProgress = new DownloadProgress{
|
||||
IsDownloading = true,
|
||||
State = DownloadState.Processing,
|
||||
Percent = dataLog.Percent,
|
||||
Time = dataLog.Time,
|
||||
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
|
||||
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
if (!_currentEpMeta.DownloadProgress.Done){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
||||
}
|
||||
|
||||
CleanupBufferedArtifacts();
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -503,16 +567,9 @@ public class HlsDownloader{
|
|||
|
||||
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
|
||||
if (cleanAll){
|
||||
// Delete downloaded files
|
||||
foreach (var file in _currentEpMeta.downloadedFiles){
|
||||
try{
|
||||
File.Delete(file); // Safe: File.Delete does nothing if file doesn't exist
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Failed to delete file '{file}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
CleanupDownloadedFiles();
|
||||
}
|
||||
|
||||
|
||||
// Delete temp directory
|
||||
try{
|
||||
if (Directory.Exists(tempDir))
|
||||
|
|
@ -565,6 +622,7 @@ public class HlsDownloader{
|
|||
}
|
||||
|
||||
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
|
||||
_cancellationToken.ThrowIfCancellationRequested();
|
||||
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
|
||||
byte[]? dec = null;
|
||||
int p = segIndex;
|
||||
|
|
@ -572,7 +630,7 @@ public class HlsDownloader{
|
|||
byte[]? part;
|
||||
if (seg.Key != null){
|
||||
var decipher = await GetKey(seg.Key, p, segOffset);
|
||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
|
||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
|
||||
var partContent = part;
|
||||
using (decipher){
|
||||
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
|
||||
|
|
@ -583,7 +641,7 @@ public class HlsDownloader{
|
|||
Interlocked.Add(ref _data.TotalBytes, dec.Length);
|
||||
}
|
||||
} else{
|
||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
|
||||
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
|
||||
dec = part;
|
||||
if (dec != null){
|
||||
Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
|
||||
|
|
@ -646,7 +704,7 @@ public class HlsDownloader{
|
|||
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
|
||||
if (!_data.Keys.ContainsKey(kUri)){
|
||||
try{
|
||||
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries);
|
||||
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries, _cancellationToken);
|
||||
if (rkey == null || rkey.Length != 16){
|
||||
throw new Exception("Key not fully downloaded or is incorrect.");
|
||||
}
|
||||
|
|
@ -662,7 +720,8 @@ public class HlsDownloader{
|
|||
return _data.Keys[kUri];
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount){
|
||||
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount, CancellationToken cancellationToken){
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Handle local file URI
|
||||
if (uri.StartsWith("file://")){
|
||||
string path = new Uri(uri).LocalPath;
|
||||
|
|
@ -680,17 +739,18 @@ public class HlsDownloader{
|
|||
request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent);
|
||||
}
|
||||
|
||||
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount);
|
||||
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){
|
||||
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount, CancellationToken cancellationToken){
|
||||
HttpResponseMessage response;
|
||||
for (int attempt = 0; attempt < retryCount + 1; attempt++){
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using (var request = CloneHttpRequestMessage(requestPara)){
|
||||
try{
|
||||
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await ReadContentAsByteArrayAsync(response.Content);
|
||||
return await ReadContentAsByteArrayAsync(response.Content, cancellationToken);
|
||||
} catch (Exception ex) when (ex is HttpRequestException or IOException){
|
||||
// Log retry attempts
|
||||
string partType = isKey ? "Key" : "Part";
|
||||
|
|
@ -700,7 +760,7 @@ public class HlsDownloader{
|
|||
if (attempt == retryCount)
|
||||
throw; // rethrow after last retry
|
||||
|
||||
await Task.Delay(_data.WaitTime);
|
||||
await Task.Delay(_data.WaitTime, cancellationToken);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
|
||||
Console.Error.WriteLine($"\tType: {ex.GetType()}");
|
||||
|
|
@ -713,14 +773,14 @@ public class HlsDownloader{
|
|||
return null; // Should not reach here
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content){
|
||||
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content, CancellationToken cancellationToken){
|
||||
using (var memoryStream = new MemoryStream())
|
||||
using (var contentStream = await content.ReadAsStreamAsync())
|
||||
using (var contentStream = await content.ReadAsStreamAsync(cancellationToken))
|
||||
using (var throttledStream = new ThrottledStream(contentStream)){
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = await throttledStream.ReadAsync(buffer, 0, buffer.Length)) > 0){
|
||||
await memoryStream.WriteAsync(buffer, 0, bytesRead);
|
||||
while ((bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0){
|
||||
await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
|
||||
}
|
||||
|
||||
return memoryStream.ToArray();
|
||||
|
|
@ -757,7 +817,7 @@ public class HlsDownloader{
|
|||
}
|
||||
|
||||
public static class HttpContentExtensions{
|
||||
public static HttpContent Clone(this HttpContent content){
|
||||
public static HttpContent? Clone(this HttpContent? content){
|
||||
if (content == null) return null;
|
||||
var memStream = new MemoryStream();
|
||||
content.CopyToAsync(memStream).Wait();
|
||||
|
|
@ -841,4 +901,4 @@ public class PartsData{
|
|||
public int First{ get; set; }
|
||||
public int Total{ get; set; }
|
||||
public int Completed{ get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,40 @@ using CRD.Utils.Http;
|
|||
using CRD.Utils.JsonConv;
|
||||
using CRD.Utils.Parser;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Microsoft.Win32;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using NuGet.Versioning;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class Helpers{
|
||||
private static readonly Regex ClientVersionRegex = new(@"(?:ANDROIDTV|Crunchyroll)/(?<version>[0-9]+(?:[._][0-9]+)*)", RegexOptions.Compiled);
|
||||
|
||||
public static string? ExtractClientVersion(string? userAgent){
|
||||
if (string.IsNullOrWhiteSpace(userAgent)){
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = ClientVersionRegex.Match(userAgent);
|
||||
return match.Success ? match.Groups["version"].Value : null;
|
||||
}
|
||||
|
||||
public static int CompareClientVersions(string? left, string? right){
|
||||
var leftVersion = ParseClientVersion(left);
|
||||
var rightVersion = ParseClientVersion(right);
|
||||
|
||||
return VersionComparer.Version.Compare(leftVersion, rightVersion);
|
||||
}
|
||||
|
||||
private static NuGetVersion ParseClientVersion(string? version){
|
||||
return NuGetVersion.TryParse(version?.Replace('_', '.') ?? "", out var nuGetVersion)
|
||||
? nuGetVersion
|
||||
: NuGetVersion.Parse("0.0.0");
|
||||
}
|
||||
|
||||
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
|
||||
try{
|
||||
serializerSettings ??= new JsonSerializerSettings();
|
||||
|
|
@ -62,6 +88,19 @@ public class Helpers{
|
|||
return clone;
|
||||
}
|
||||
|
||||
public static int GetRetryDelaySeconds(CrDownloadOptions options, int retryAttemptCount){
|
||||
return GetRetryDelaySeconds(options.PlaybackRateLimitRetryDelaySeconds, options.RetryMaxDelaySeconds, retryAttemptCount);
|
||||
}
|
||||
|
||||
public static int GetRetryDelaySeconds(int baseDelaySeconds, int maxDelaySeconds, int retryAttemptCount){
|
||||
int baseDelay = Math.Max(1, baseDelaySeconds);
|
||||
int maxDelay = Math.Max(baseDelay, maxDelaySeconds);
|
||||
|
||||
int attempt = Math.Max(0, retryAttemptCount);
|
||||
double delay = baseDelay * Math.Pow(2, attempt);
|
||||
return (int)Math.Min(maxDelay, delay);
|
||||
}
|
||||
|
||||
public static T? DeepCopy<T>(T obj){
|
||||
var settings = new JsonSerializerSettings{
|
||||
ContractResolver = new DefaultContractResolver{
|
||||
|
|
@ -193,7 +232,7 @@ public class Helpers{
|
|||
}
|
||||
}
|
||||
|
||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command){
|
||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command, CancellationToken cancellationToken = default){
|
||||
try{
|
||||
using (var process = new Process()){
|
||||
process.StartInfo.FileName = bin;
|
||||
|
|
@ -224,7 +263,17 @@ public class Helpers{
|
|||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
await using var registration = cancellationToken.Register(() => {
|
||||
try{
|
||||
if (!process.HasExited){
|
||||
process.Kill(true);
|
||||
}
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
bool isSuccess = process.ExitCode == 0;
|
||||
|
||||
|
|
@ -236,69 +285,162 @@ public class Helpers{
|
|||
}
|
||||
}
|
||||
|
||||
public static void DeleteFile(string filePath){
|
||||
public static bool DeleteFile(string filePath, int maxRetries = 5, int delayMs = 150){
|
||||
if (string.IsNullOrEmpty(filePath)){
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try{
|
||||
if (File.Exists(filePath)){
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++){
|
||||
try{
|
||||
if (!File.Exists(filePath)){
|
||||
return true;
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
return true;
|
||||
} catch (Exception ex) when (attempt < maxRetries - 1 && (ex is IOException || ex is UnauthorizedAccessException)){
|
||||
Thread.Sleep(delayMs * (attempt + 1));
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: file remained locked after {maxRetries} attempts.");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string GetAvailableDestinationPath(string destinationPath){
|
||||
if (!File.Exists(destinationPath)){
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(destinationPath) ?? string.Empty;
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(destinationPath);
|
||||
var extension = Path.GetExtension(destinationPath);
|
||||
var counter = 1;
|
||||
|
||||
string candidatePath;
|
||||
do{
|
||||
candidatePath = Path.Combine(directory, $"{fileNameWithoutExtension}({counter}){extension}");
|
||||
counter++;
|
||||
} while (File.Exists(candidatePath));
|
||||
|
||||
return candidatePath;
|
||||
}
|
||||
|
||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
|
||||
Process? process = null;
|
||||
DataReceivedEventHandler? outputHandler = null;
|
||||
DataReceivedEventHandler? errorHandler = null;
|
||||
|
||||
try{
|
||||
using (var process = new Process()){
|
||||
process.StartInfo.WorkingDirectory = workingDir;
|
||||
process.StartInfo.FileName = bin;
|
||||
process.StartInfo.Arguments = command;
|
||||
process.StartInfo.RedirectStandardOutput = true;
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
process.StartInfo.UseShellExecute = false;
|
||||
process.StartInfo.CreateNoWindow = true;
|
||||
process = new Process{
|
||||
StartInfo = new ProcessStartInfo{
|
||||
WorkingDirectory = workingDir,
|
||||
FileName = bin,
|
||||
Arguments = command,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
process.OutputDataReceived += (sender, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.WriteLine(e.Data);
|
||||
}
|
||||
};
|
||||
outputHandler = (_, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.WriteLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.Error.WriteLine($"{e.Data}");
|
||||
}
|
||||
};
|
||||
errorHandler = (_, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.Error.WriteLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
process.Start();
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
bool isSuccess = process.ExitCode == 0;
|
||||
await process.WaitForExitAsync();
|
||||
process.WaitForExit();
|
||||
|
||||
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
||||
}
|
||||
return (IsOk: process.ExitCode == 0, ErrorCode: process.ExitCode);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
||||
return (IsOk: false, ErrorCode: -1);
|
||||
} finally{
|
||||
if (process != null){
|
||||
if (outputHandler != null){
|
||||
process.OutputDataReceived -= outputHandler;
|
||||
}
|
||||
|
||||
if (errorHandler != null){
|
||||
process.ErrorDataReceived -= errorHandler;
|
||||
}
|
||||
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
|
||||
public static IEnumerable<string> GetQualityOption(VideoPreset preset){
|
||||
if (preset.Crf is -1)
|
||||
return [];
|
||||
|
||||
var q = preset.Crf.ToString();
|
||||
|
||||
return preset.Codec switch{
|
||||
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
|
||||
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
|
||||
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
|
||||
_ =>["-crf", preset.Crf.ToString()]
|
||||
"h264_nvenc" or "hevc_nvenc"
|
||||
=> preset.Crf is >= 0 and <= 51 ? ["-cq", q] : [],
|
||||
|
||||
"h264_qsv" or "hevc_qsv"
|
||||
=> preset.Crf is >= 1 and <= 51 ? ["-global_quality", q] : [],
|
||||
|
||||
"h264_amf"
|
||||
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q, "-qp_b", q] : [],
|
||||
|
||||
"hevc_amf"
|
||||
=> preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q] : [],
|
||||
|
||||
_ // libx264/libx265/etc.
|
||||
=> preset.Crf >= 0 ? ["-crf", q] : []
|
||||
};
|
||||
}
|
||||
|
||||
public static List<string> BuildFFmpegArgsForPreset(string inputFilePath, VideoPreset preset, string outputFilePath){
|
||||
var args = new List<string>{
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", inputFilePath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(preset.Codec)){
|
||||
args.Add("-c:v");
|
||||
args.Add(preset.Codec);
|
||||
|
||||
args.AddRange(GetQualityOption(preset));
|
||||
|
||||
args.Add("-vf");
|
||||
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
|
||||
}
|
||||
|
||||
foreach (var param in preset.AdditionalParameters){
|
||||
args.AddRange(SplitArguments(param));
|
||||
}
|
||||
|
||||
args.Add(outputFilePath);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
|
||||
string inputFilePath,
|
||||
VideoPreset preset,
|
||||
|
|
@ -311,29 +453,7 @@ public class Helpers{
|
|||
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
|
||||
|
||||
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
|
||||
|
||||
var args = new List<string>{
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", inputFilePath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(preset.Codec)){
|
||||
args.Add("-c:v");
|
||||
args.Add(preset.Codec);
|
||||
}
|
||||
|
||||
args.AddRange(GetQualityOption(preset));
|
||||
|
||||
args.Add("-vf");
|
||||
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
|
||||
|
||||
foreach (var param in preset.AdditionalParameters){
|
||||
args.AddRange(SplitArguments(param));
|
||||
}
|
||||
|
||||
args.Add(tempOutput);
|
||||
var args = BuildFFmpegArgsForPreset(inputFilePath, preset, tempOutput);
|
||||
|
||||
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
|
||||
int exitCode;
|
||||
|
|
@ -409,7 +529,7 @@ public class Helpers{
|
|||
return args;
|
||||
}
|
||||
|
||||
private static string BuildCommandString(string exe, IEnumerable<string> args){
|
||||
public static string BuildCommandString(string exe, IEnumerable<string> args){
|
||||
static string Quote(string s){
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return "\"\"";
|
||||
|
|
@ -960,4 +1080,4 @@ public class Helpers{
|
|||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,9 +112,10 @@ public class HttpClientReq{
|
|||
return handler;
|
||||
}
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
|
||||
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
|
||||
bool allowChallengeBypass = true){
|
||||
string content = string.Empty;
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try{
|
||||
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
|
||||
AttachCookies(request, cookieStore);
|
||||
|
|
@ -131,7 +132,7 @@ public class HttpClientReq{
|
|||
retryRequest, GetCookiesForRequest(cookieStore));
|
||||
|
||||
if (!solverResult.IsOk){
|
||||
return (false, solverResult.ResponseContent, "Challenge bypass failed");
|
||||
return (false, solverResult.ResponseContent, "Challenge bypass failed",headers);
|
||||
}
|
||||
|
||||
// foreach (var cookie in solverResult.Cookies){
|
||||
|
|
@ -139,30 +140,36 @@ public class HttpClientReq{
|
|||
// AddCookie(cookie.Domain, cookie, cookieStore);
|
||||
// }
|
||||
|
||||
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "");
|
||||
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "",headers);
|
||||
}
|
||||
|
||||
return (false, content, "Cloudflare challenge detected");
|
||||
return (false, content, "Cloudflare challenge detected",headers);
|
||||
}
|
||||
|
||||
content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
foreach (var header in response.Headers)
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
|
||||
foreach (var header in response.Content.Headers)
|
||||
headers[header.Key] = string.Join(", ", header.Value);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
|
||||
|
||||
return (IsOk: true, ResponseContent: content, error: "");
|
||||
return (IsOk: true, ResponseContent: content, error: "",headers);
|
||||
} catch (Exception e){
|
||||
if (!suppressError){
|
||||
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
||||
}
|
||||
return (IsOk: false, ResponseContent: content, error: "");
|
||||
return (IsOk: false, ResponseContent: content, error: "",headers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
|
||||
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured");
|
||||
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
|
||||
if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured",[]);
|
||||
string content = string.Empty;
|
||||
try{
|
||||
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
|
||||
|
|
@ -170,13 +177,13 @@ public class HttpClientReq{
|
|||
|
||||
content = flareSolverrResponses.ResponseContent;
|
||||
|
||||
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "");
|
||||
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "",[]);
|
||||
} catch (Exception e){
|
||||
if (!suppressError){
|
||||
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
||||
}
|
||||
|
||||
return (IsOk: false, ResponseContent: content, error: "");
|
||||
return (IsOk: false, ResponseContent: content, error: "",[]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
|
|
@ -108,55 +108,27 @@ public class FontsManager{
|
|||
{ "Webdings", "webdings.ttf" }
|
||||
};
|
||||
|
||||
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
|
||||
|
||||
|
||||
private readonly FontIndex index = new();
|
||||
private int _fontSourceNoticePrinted;
|
||||
|
||||
private void EnsureIndex(string fontsDir){
|
||||
index.Rebuild(fontsDir);
|
||||
index.Rebuild(GetFontSearchDirectories(fontsDir));
|
||||
}
|
||||
|
||||
public async Task GetFontsAsync(){
|
||||
Console.WriteLine("Downloading fonts...");
|
||||
var fonts = Fonts.Values.ToList();
|
||||
|
||||
foreach (var font in fonts){
|
||||
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
|
||||
|
||||
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
|
||||
continue;
|
||||
}
|
||||
|
||||
var fontFolder = Path.GetDirectoryName(fontLoc);
|
||||
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
|
||||
File.Delete(fontLoc);
|
||||
|
||||
try{
|
||||
if (!Directory.Exists(fontFolder))
|
||||
Directory.CreateDirectory(fontFolder!);
|
||||
} catch (Exception e){
|
||||
Console.WriteLine($"Failed to create directory: {e.Message}");
|
||||
}
|
||||
|
||||
var fontUrl = root + font;
|
||||
|
||||
var httpClient = HttpClientReq.Instance.GetHttpClient();
|
||||
try{
|
||||
var response = await httpClient.GetAsync(fontUrl);
|
||||
if (response.IsSuccessStatusCode){
|
||||
var fontData = await response.Content.ReadAsByteArrayAsync();
|
||||
await File.WriteAllBytesAsync(fontLoc, fontData);
|
||||
Console.WriteLine($"Downloaded: {font}");
|
||||
} else{
|
||||
Console.Error.WriteLine($"Failed to download: {font}");
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
|
||||
}
|
||||
public Task GetFontsAsync(){
|
||||
try{
|
||||
Directory.CreateDirectory(CfgManager.PathFONTS_DIR);
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Failed to create fonts directory '{CfgManager.PathFONTS_DIR}': {e.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("All required fonts downloaded!");
|
||||
if (Interlocked.Exchange(ref _fontSourceNoticePrinted, 1) == 0){
|
||||
Console.WriteLine("Crunchyroll-hosted subtitle fonts are no longer available.");
|
||||
Console.WriteLine($"Font muxing now uses local fonts from '{CfgManager.PathFONTS_DIR}' and system font directories.");
|
||||
Console.WriteLine("Copy any missing subtitle fonts into the local fonts folder if muxing reports them as missing.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static List<string> ExtractFontsFromAss(string ass, bool checkTypesettingFonts){
|
||||
|
|
@ -273,7 +245,7 @@ public class FontsManager{
|
|||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}");
|
||||
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}\n\nAdd the missing font files to:\n{CfgManager.PathFONTS_DIR}");
|
||||
|
||||
return fontsList;
|
||||
}
|
||||
|
|
@ -288,8 +260,8 @@ public class FontsManager{
|
|||
return true;
|
||||
|
||||
if (Fonts.TryGetValue(req, out var crFile)){
|
||||
var p = Path.Combine(fontsDir, crFile);
|
||||
if (File.Exists(p)){
|
||||
var p = FindKnownFontFile(crFile, fontsDir);
|
||||
if (!string.IsNullOrEmpty(p)){
|
||||
resolvedPath = p;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -303,27 +275,44 @@ public class FontsManager{
|
|||
return true;
|
||||
|
||||
if (Fonts.TryGetValue(family, out var crFamilyFile)){
|
||||
var p = Path.Combine(fontsDir, crFamilyFile);
|
||||
if (File.Exists(p)){
|
||||
var p = FindKnownFontFile(crFamilyFile, fontsDir);
|
||||
if (!string.IsNullOrEmpty(p)){
|
||||
resolvedPath = p;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqNoSpace = RemoveSpaces(req);
|
||||
|
||||
foreach (var kv in Fonts){
|
||||
if (RemoveSpaces(kv.Key).Equals(reqNoSpace, StringComparison.OrdinalIgnoreCase)){
|
||||
var p = FindKnownFontFile(kv.Value, fontsDir);
|
||||
if (!string.IsNullOrEmpty(p)){
|
||||
resolvedPath = p;
|
||||
isExactMatch = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string StripStyleSuffix(string name){
|
||||
var n = name;
|
||||
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$",
|
||||
"", RegexOptions.IgnoreCase).Trim();
|
||||
var styleWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase){
|
||||
"Bold", "Italic", "Oblique", "Regular", "Black",
|
||||
"Light", "Medium", "Semi", "Condensed"
|
||||
};
|
||||
|
||||
return n;
|
||||
var filtered = parts.Where(p => !styleWords.Contains(p)).ToList();
|
||||
|
||||
return filtered.Count > 0 ? string.Join(" ", filtered) : name;
|
||||
}
|
||||
|
||||
public static string NormalizeFontKey(string s){
|
||||
private static string NormalizeFontKey(string s){
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return string.Empty;
|
||||
|
||||
|
|
@ -332,17 +321,24 @@ public class FontsManager{
|
|||
if (s.StartsWith("@"))
|
||||
s = s.Substring(1);
|
||||
|
||||
// Convert camel case (TimesNewRoman → Times New Roman)
|
||||
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
|
||||
|
||||
// unify separators
|
||||
s = s.Replace('_', ' ').Replace('-', ' ');
|
||||
|
||||
s = Regex.Replace(s, @"\s+", " ").Trim();
|
||||
// remove MT suffix (ArialMT → Arial)
|
||||
s = Regex.Replace(s, @"MT$", "", RegexOptions.IgnoreCase);
|
||||
|
||||
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase);
|
||||
// collapse spaces
|
||||
s = Regex.Replace(s, @"\s+", " ").Trim();
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string RemoveSpaces(string s)
|
||||
=> s.Replace(" ", "");
|
||||
|
||||
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
|
||||
var baseName = Path.GetFileName(path);
|
||||
|
||||
|
|
@ -356,23 +352,79 @@ public class FontsManager{
|
|||
return $"{hash}-{baseName}";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetFontSearchDirectories(string fontsDir){
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var paths = new List<string>();
|
||||
|
||||
void AddIfUsable(string? dir){
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
return;
|
||||
|
||||
try{
|
||||
var fullPath = Path.GetFullPath(dir);
|
||||
if (Directory.Exists(fullPath) && seen.Add(fullPath))
|
||||
paths.Add(fullPath);
|
||||
} catch{
|
||||
// ignore invalid paths
|
||||
}
|
||||
}
|
||||
|
||||
AddIfUsable(fontsDir);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
|
||||
AddIfUsable(Environment.GetFolderPath(Environment.SpecialFolder.Fonts));
|
||||
} else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)){
|
||||
AddIfUsable("/System/Library/Fonts");
|
||||
AddIfUsable("/Library/Fonts");
|
||||
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Fonts"));
|
||||
} else{
|
||||
AddIfUsable("/usr/share/fonts");
|
||||
AddIfUsable("/usr/local/share/fonts");
|
||||
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".fonts"));
|
||||
AddIfUsable(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "fonts"));
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static string FindKnownFontFile(string fileName, string fontsDir){
|
||||
foreach (var dir in GetFontSearchDirectories(fontsDir)){
|
||||
var path = Path.Combine(dir, fileName);
|
||||
if (File.Exists(path))
|
||||
return path;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
|
||||
private sealed class FontIndex{
|
||||
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Rebuild(string fontsDir){
|
||||
public void Rebuild(IEnumerable<string> fontDirs){
|
||||
map.Clear();
|
||||
if (!Directory.Exists(fontsDir)) return;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
|
||||
foreach (var fontsDir in fontDirs){
|
||||
if (!Directory.Exists(fontsDir))
|
||||
continue;
|
||||
|
||||
foreach (var desc in LoadDescriptions(path)){
|
||||
foreach (var alias in BuildAliases(desc)){
|
||||
Add(alias, path);
|
||||
try{
|
||||
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
|
||||
continue;
|
||||
|
||||
try{
|
||||
foreach (var desc in LoadDescriptions(path)){
|
||||
foreach (var alias in BuildAliases(desc)){
|
||||
Add(alias, path);
|
||||
}
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Failed to inspect font '{path}': {e.Message}");
|
||||
}
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Failed to scan font directory '{fontsDir}': {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -440,9 +492,9 @@ public class FontsManager{
|
|||
}
|
||||
|
||||
private static IEnumerable<string> BuildAliases(FontDescription d){
|
||||
var family = d.FontFamilyInvariantCulture.Trim();
|
||||
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
|
||||
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily"
|
||||
var family = d.FontFamilyInvariantCulture?.Trim() ?? string.Empty;
|
||||
var sub = d.FontSubFamilyNameInvariantCulture?.Trim() ?? string.Empty; // Regular/Bold/Italic
|
||||
var full = d.FontNameInvariantCulture?.Trim() ?? string.Empty; // "Family Subfamily"
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(family)) yield return family;
|
||||
if (!string.IsNullOrWhiteSpace(full)) yield return full;
|
||||
|
|
@ -461,4 +513,4 @@ public class FontsManager{
|
|||
public class SubtitleFonts{
|
||||
public LanguageItem Language{ get; set; } = new();
|
||||
public Dictionary<string, string> Fonts{ get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Muxing.Commands;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
|
|
@ -27,7 +28,7 @@ public class Merger{
|
|||
}
|
||||
|
||||
|
||||
public async Task<bool> Merge(string type, string bin){
|
||||
public async Task<bool> Merge(string type, string bin, CancellationToken cancellationToken = default){
|
||||
string command = type switch{
|
||||
"ffmpeg" => FFmpeg(),
|
||||
"mkvmerge" => MkvMerge(),
|
||||
|
|
@ -40,7 +41,7 @@ public class Merger{
|
|||
}
|
||||
|
||||
Console.WriteLine($"[{type}] Started merging");
|
||||
var result = await Helpers.ExecuteCommandAsync(bin, command);
|
||||
var result = await Helpers.ExecuteCommandAsync(bin, command, cancellationToken);
|
||||
|
||||
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
|
||||
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ public class SyncingHelper{
|
|||
Pixels = GetPixelsArray(f.FilePath)
|
||||
}).ToList();
|
||||
|
||||
var delay = 0.0;
|
||||
var delay = double.NaN;
|
||||
|
||||
foreach (var baseFrame in baseFrames){
|
||||
var baseFramePixels = GetPixelsArray(baseFrame.FilePath);
|
||||
|
|
@ -236,4 +236,4 @@ public class SyncingHelper{
|
|||
|
||||
return delay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ public class VideoSyncer{
|
|||
public static async Task<(double offSet, double startOffset, double endOffset, double lengthDiff)> ProcessVideo(string baseVideoPath, string compareVideoPath){
|
||||
string baseFramesDir, baseFramesDirEnd;
|
||||
string compareFramesDir, compareFramesDirEnd;
|
||||
string cleanupDir;
|
||||
string cleanupDir = string.Empty;
|
||||
double baseEndWindowOffset = 0;
|
||||
double compareEndWindowOffset = 0;
|
||||
try{
|
||||
var tempDir = CfgManager.PathTEMP_DIR;
|
||||
string uuid = Guid.NewGuid().ToString();
|
||||
|
|
@ -46,8 +48,13 @@ public class VideoSyncer{
|
|||
return (-100, 0, 0, 0);
|
||||
}
|
||||
|
||||
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
||||
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
||||
var baseEndWindowDuration = Math.Min(360, baseVideoDurationTimeSpan.Value.TotalSeconds);
|
||||
var compareEndWindowDuration = Math.Min(360, compareVideoDurationTimeSpan.Value.TotalSeconds);
|
||||
baseEndWindowOffset = Math.Max(0, baseVideoDurationTimeSpan.Value.TotalSeconds - baseEndWindowDuration);
|
||||
compareEndWindowOffset = Math.Max(0, compareVideoDurationTimeSpan.Value.TotalSeconds - compareEndWindowDuration);
|
||||
|
||||
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseEndWindowOffset, baseEndWindowDuration);
|
||||
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareEndWindowOffset, compareEndWindowDuration);
|
||||
|
||||
if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){
|
||||
Console.Error.WriteLine("Failed to extract Frames to Compare");
|
||||
|
|
@ -57,24 +64,24 @@ public class VideoSyncer{
|
|||
// Load frames from start of the videos
|
||||
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate)
|
||||
}).ToList();
|
||||
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate, 0)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate)
|
||||
}).ToList();
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate, 0)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
// Load frames from end of the videos
|
||||
var baseFramesEnd = Directory.GetFiles(baseFramesDirEnd).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate)
|
||||
}).ToList();
|
||||
Time = GetTimeFromFileName(fp, extractFramesBaseEnd.frameRate, baseEndWindowOffset)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate)
|
||||
}).ToList();
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate, compareEndWindowOffset)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
|
||||
// Calculate offsets
|
||||
|
|
@ -83,13 +90,14 @@ public class VideoSyncer{
|
|||
|
||||
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
|
||||
|
||||
endOffset += lengthDiff;
|
||||
if (double.IsNaN(startOffset) || double.IsNaN(endOffset)){
|
||||
Console.Error.WriteLine("Couldn't find enough matching frames to sync dub.");
|
||||
return (-100, startOffset, endOffset, lengthDiff);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Start offset: {startOffset} seconds");
|
||||
Console.WriteLine($"End offset: {endOffset} seconds");
|
||||
|
||||
CleanupDirectory(cleanupDir);
|
||||
|
||||
baseFramesStart.Clear();
|
||||
baseFramesEnd.Clear();
|
||||
compareFramesStart.Clear();
|
||||
|
|
@ -112,21 +120,23 @@ public class VideoSyncer{
|
|||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return (-100, 0, 0, 0);
|
||||
} finally{
|
||||
CleanupDirectory(cleanupDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupDirectory(string dirPath){
|
||||
if (Directory.Exists(dirPath)){
|
||||
if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)){
|
||||
Directory.Delete(dirPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetTimeFromFileName(string fileName, double frameRate){
|
||||
private static double GetTimeFromFileName(string fileName, double frameRate, double timeOffset){
|
||||
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
|
||||
if (match.Success){
|
||||
return int.Parse(match.Groups[1].Value) / frameRate;
|
||||
return timeOffset + int.Parse(match.Groups[1].Value) / frameRate;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return timeOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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){
|
||||
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){
|
||||
var locale = match.Groups[1].Value; // Capture the locale part
|
||||
var id = match.Groups[2].Value; // Capture the ID part
|
||||
await QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
||||
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Notifications;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.ViewModels;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
|
||||
namespace CRD.Utils.Structs.Crunchyroll;
|
||||
|
||||
|
|
@ -21,6 +23,9 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("remove_finished_downloads")]
|
||||
public bool RemoveFinishedDownload{ get; set; }
|
||||
|
||||
[JsonProperty("persist_queue")]
|
||||
public bool PersistQueue{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int Timeout{ get; set; }
|
||||
|
||||
|
|
@ -29,6 +34,12 @@ public class CrDownloadOptions{
|
|||
|
||||
[JsonProperty("retry_attempts")]
|
||||
public int RetryAttempts{ get; set; }
|
||||
|
||||
[JsonProperty("playback_rate_limit_retry_delay_seconds")]
|
||||
public int PlaybackRateLimitRetryDelaySeconds{ get; set; }
|
||||
|
||||
[JsonProperty("retry_max_delay_seconds")]
|
||||
public int RetryMaxDelaySeconds{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string Force{ get; set; } = "";
|
||||
|
|
@ -65,6 +76,9 @@ public class CrDownloadOptions{
|
|||
|
||||
[JsonProperty("download_finished_execute_path")]
|
||||
public string? DownloadFinishedExecutePath{ get; set; }
|
||||
|
||||
[JsonProperty("notifications")]
|
||||
public NotificationSettings? NotificationSettings{ get; set; }
|
||||
|
||||
[JsonProperty("download_only_with_all_selected_dubsub")]
|
||||
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
|
||||
|
|
@ -92,6 +106,9 @@ public class CrDownloadOptions{
|
|||
|
||||
[JsonProperty("history_include_cr_artists")]
|
||||
public bool HistoryIncludeCrArtists{ get; set; }
|
||||
|
||||
[JsonProperty("history_remove_missing_episodes")]
|
||||
public bool HistoryRemoveMissingEpisodes{ get; set; } = true;
|
||||
|
||||
[JsonProperty("history_lang")]
|
||||
public string? HistoryLang{ get; set; }
|
||||
|
|
@ -111,6 +128,12 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("history_auto_refresh_mode")]
|
||||
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
|
||||
|
||||
[JsonProperty("history_auto_refresh_add_to_queue")]
|
||||
public bool HistoryAutoRefreshAddToQueue{ get; set; } = true;
|
||||
|
||||
[JsonProperty("tracked_series_release_last_check_utc")]
|
||||
public DateTime? TrackedSeriesReleaseLastCheckUtc{ get; set; }
|
||||
|
||||
|
||||
[JsonProperty("sonarr_properties")]
|
||||
public SonarrProperties? SonarrProperties{ get; set; }
|
||||
|
|
@ -224,6 +247,9 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("download_part_size")]
|
||||
public int Partsize{ get; set; }
|
||||
|
||||
[JsonProperty("dub_download_delay_seconds")]
|
||||
public int DubDownloadDelaySeconds{ get; set; }
|
||||
|
||||
[JsonProperty("soft_subs")]
|
||||
public List<string> DlSubs{ get; set; } =[];
|
||||
|
||||
|
|
@ -317,6 +343,9 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("mux_sync_dubs")]
|
||||
public bool SyncTiming{ get; set; }
|
||||
|
||||
[JsonProperty("mux_sync_fallback_full_quality")]
|
||||
public bool SyncTimingFullQualityFallback{ get; set; }
|
||||
|
||||
[JsonProperty("mux_sync_hwaccel")]
|
||||
public string? FfmpegHwAccelFlag{ get; set; }
|
||||
|
||||
|
|
@ -360,4 +389,46 @@ public class CrDownloadOptions{
|
|||
public bool SearchFetchFeaturedMusic{ get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public void NormalizeNotificationSettings(){
|
||||
NotificationSettings ??= new NotificationSettings();
|
||||
|
||||
var hasProviders = NotificationSettings.Providers.Count > 0;
|
||||
|
||||
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||
if (hasProviders){
|
||||
if (soundProvider.Events.Count == 0){
|
||||
soundProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||
}
|
||||
} else{
|
||||
soundProvider.Enabled = DownloadFinishedPlaySound;
|
||||
soundProvider.Path = DownloadFinishedSoundPath ?? string.Empty;
|
||||
soundProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||
}
|
||||
|
||||
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||
if (hasProviders){
|
||||
if (executeProvider.Events.Count == 0){
|
||||
executeProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||
}
|
||||
} else{
|
||||
executeProvider.Enabled = DownloadFinishedExecute;
|
||||
executeProvider.Path = DownloadFinishedExecutePath ?? string.Empty;
|
||||
executeProvider.Events.Add(NotificationEventType.QueueFinished);
|
||||
}
|
||||
|
||||
SyncLegacyNotificationFields();
|
||||
}
|
||||
|
||||
public void SyncLegacyNotificationFields(){
|
||||
NotificationSettings ??= new NotificationSettings();
|
||||
|
||||
var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||
DownloadFinishedPlaySound = soundProvider.Enabled;
|
||||
DownloadFinishedSoundPath = soundProvider.Path;
|
||||
|
||||
var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||
DownloadFinishedExecute = executeProvider.Enabled;
|
||||
DownloadFinishedExecutePath = executeProvider.Path;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,12 @@ public class CrunchyEpisode : IHistorySource{
|
|||
}
|
||||
|
||||
public bool IsSpecialSeason(){
|
||||
if (SeasonTitle.Contains("OVA", StringComparison.Ordinal) ||
|
||||
SeasonTitle.Contains("Special", StringComparison.Ordinal) ||
|
||||
SeasonTitle.Contains("Extra", StringComparison.Ordinal)){
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Identifier)){
|
||||
return false;
|
||||
}
|
||||
|
|
@ -285,7 +291,20 @@ public class CrunchyEpisode : IHistorySource{
|
|||
}
|
||||
|
||||
public SeriesType GetSeriesType(){
|
||||
return SeriesType.Series;
|
||||
if (string.IsNullOrWhiteSpace(Identifier))
|
||||
return SeriesType.Series;
|
||||
|
||||
var parts = Identifier.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
return SeriesType.Series;
|
||||
|
||||
return parts[1] switch{
|
||||
var p when p.StartsWith("S", StringComparison.OrdinalIgnoreCase) => SeriesType.Series,
|
||||
var p when p.StartsWith("M", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||
var p when p.StartsWith("T", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||
var p when parts.Length == 2 && p.StartsWith("E", StringComparison.OrdinalIgnoreCase) => SeriesType.Movie,
|
||||
_ => SeriesType.Series
|
||||
};
|
||||
}
|
||||
|
||||
public EpisodeType GetEpisodeType(){
|
||||
|
|
@ -373,7 +392,6 @@ public class CrunchyEpMeta{
|
|||
public string? AbsolutEpisodeNumberE{ get; set; }
|
||||
public string? Image{ get; set; }
|
||||
public string? ImageBig{ get; set; }
|
||||
public bool Paused{ get; set; }
|
||||
public DownloadProgress DownloadProgress{ get; set; } = new();
|
||||
|
||||
public List<string>? SelectedDubs{ get; set; }
|
||||
|
|
@ -385,6 +403,7 @@ public class CrunchyEpMeta{
|
|||
public string? DownloadPath{ get; set; }
|
||||
public string? VideoQuality{ get; set; }
|
||||
public List<string> DownloadSubs{ get; set; } =[];
|
||||
public string? TempFileSuffix{ get; set; }
|
||||
public bool Music{ get; set; }
|
||||
|
||||
public string Resolution{ get; set; }
|
||||
|
|
@ -399,18 +418,74 @@ public class CrunchyEpMeta{
|
|||
|
||||
public bool HighlightAllAvailable{ get; set; }
|
||||
|
||||
public CancellationTokenSource Cts { get; } = new();
|
||||
[JsonIgnore]
|
||||
public CancellationTokenSource Cts { get; private set; } = new();
|
||||
|
||||
public void RenewCancellationToken(){
|
||||
if (!Cts.IsCancellationRequested){
|
||||
return;
|
||||
}
|
||||
|
||||
Cts.Dispose();
|
||||
Cts = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
public void CancelDownload(){
|
||||
if (Cts.IsCancellationRequested){
|
||||
return;
|
||||
}
|
||||
|
||||
Cts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public class DownloadProgress{
|
||||
public bool IsDownloading = false;
|
||||
public bool Done = false;
|
||||
public bool Error = false;
|
||||
public DownloadState State{ get; set; } = DownloadState.Queued;
|
||||
public DownloadState ResumeState{ get; set; } = DownloadState.Downloading;
|
||||
public string Doing = string.Empty;
|
||||
public DateTimeOffset? RetryAtUtc{ get; set; }
|
||||
public int RetryAttemptCount{ get; set; }
|
||||
|
||||
public int Percent{ get; set; }
|
||||
public double Time{ get; set; }
|
||||
public double DownloadSpeedBytes{ get; set; }
|
||||
|
||||
public bool IsQueued => State == DownloadState.Queued;
|
||||
public bool IsDownloading => State == DownloadState.Downloading;
|
||||
public bool IsPaused => State == DownloadState.Paused;
|
||||
public bool IsProcessing => State == DownloadState.Processing;
|
||||
public bool IsDone => State == DownloadState.Done;
|
||||
public bool IsError => State == DownloadState.Error;
|
||||
public bool IsFinished => State is DownloadState.Done or DownloadState.Error;
|
||||
public bool IsRunnable => State is DownloadState.Queued or DownloadState.Error;
|
||||
public bool IsWaitingForRetry => RetryAtUtc.HasValue && RetryAtUtc.Value > DateTimeOffset.UtcNow;
|
||||
|
||||
public void ResetForRetry(){
|
||||
State = DownloadState.Queued;
|
||||
ResumeState = DownloadState.Downloading;
|
||||
Percent = 0;
|
||||
Time = 0;
|
||||
DownloadSpeedBytes = 0;
|
||||
Doing = string.Empty;
|
||||
RetryAtUtc = null;
|
||||
RetryAttemptCount = 0;
|
||||
}
|
||||
|
||||
public void ScheduleRetry(TimeSpan delay, string doing){
|
||||
State = DownloadState.Queued;
|
||||
ResumeState = DownloadState.Downloading;
|
||||
Percent = 0;
|
||||
Time = 0;
|
||||
DownloadSpeedBytes = 0;
|
||||
Doing = doing;
|
||||
RetryAtUtc = DateTimeOffset.UtcNow.Add(delay);
|
||||
RetryAttemptCount++;
|
||||
}
|
||||
|
||||
public void ClearRetryState(){
|
||||
RetryAtUtc = null;
|
||||
RetryAttemptCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public class CrunchyEpMetaData{
|
||||
|
|
@ -436,4 +511,4 @@ public class CrunchyEpMetaData{
|
|||
public class CrunchyRollEpisodeData{
|
||||
public string Key{ get; set; }
|
||||
public EpisodeAndLanguage EpisodeAndLanguages{ get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,22 @@ namespace CRD.Utils.Structs.Crunchyroll;
|
|||
|
||||
public class StreamError{
|
||||
[JsonPropertyName("error")]
|
||||
public string Error{ get; set; }
|
||||
public string? Error{ get; set; }
|
||||
|
||||
[JsonPropertyName("activeStreams")]
|
||||
public List<ActiveStream> ActiveStreams{ get; set; } = new ();
|
||||
|
||||
[JsonIgnore]
|
||||
public string? RawJson{ get; set; }
|
||||
|
||||
public static StreamError? FromJson(string json){
|
||||
try{
|
||||
return Helpers.Deserialize<StreamError>(json,null);
|
||||
var error = Helpers.Deserialize<StreamError>(json,null);
|
||||
if (error != null){
|
||||
error.RawJson = json;
|
||||
}
|
||||
|
||||
return error;
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return null;
|
||||
|
|
@ -23,6 +31,14 @@ public class StreamError{
|
|||
public bool IsTooManyActiveStreamsError(){
|
||||
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
|
||||
}
|
||||
|
||||
public bool IsRateLimitError(){
|
||||
return IsPlaybackRateLimitError();
|
||||
}
|
||||
|
||||
public bool IsPlaybackRateLimitError(){
|
||||
return Error?.Contains("4294") == true || RawJson?.Contains("4294") == true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ActiveStream{
|
||||
|
|
@ -91,4 +107,4 @@ public class ActiveStream{
|
|||
|
||||
[JsonPropertyName("lastKeepAliveTimestamp")]
|
||||
public long LastKeepAliveTimestamp{ get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ public class DownloadResponse{
|
|||
public string VideoTitle{ get; set; }
|
||||
public bool Error{ get; set; }
|
||||
public string ErrorText{ get; set; }
|
||||
public bool RetrySuggested{ get; set; }
|
||||
public int RetryDelaySeconds{ get; set; }
|
||||
}
|
||||
|
||||
public class DownloadedMedia : SxItem{
|
||||
|
|
@ -133,6 +135,7 @@ public class DownloadedMedia : SxItem{
|
|||
public bool IsPrimary{ get; set; }
|
||||
|
||||
public int bitrate{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
public bool? Cc{ get; set; }
|
||||
public bool? Signs{ get; set; }
|
||||
|
||||
|
|
@ -211,4 +214,4 @@ public class SeasonDialogArgs{
|
|||
Series = series;
|
||||
Season = season;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
[JsonProperty("episode_was_downloaded")]
|
||||
public bool WasDownloaded{ get; set; }
|
||||
|
||||
[JsonProperty("episode_tracked_series_release_notified")]
|
||||
public bool TrackedSeriesReleaseNotified{ get; set; }
|
||||
|
||||
[JsonProperty("episode_special_episode")]
|
||||
public bool SpecialEpisode{ get; set; }
|
||||
|
||||
|
|
@ -43,6 +46,9 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
[JsonProperty("episode_type")]
|
||||
public EpisodeType EpisodeType{ get; set; } = EpisodeType.Unknown;
|
||||
|
||||
[JsonProperty("episode_series_type")]
|
||||
public SeriesType EpisodeSeriesType{ get; set; } = SeriesType.Unknown;
|
||||
|
||||
[JsonProperty("episode_thumbnail_url")]
|
||||
public string? ThumbnailImageUrl{ get; set; }
|
||||
|
||||
|
|
@ -86,7 +92,7 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
public Bitmap? ThumbnailImage{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsImageLoaded{ get; private set; } = false;
|
||||
public bool IsImageLoaded{ get; private set; }
|
||||
|
||||
public async Task LoadImage(){
|
||||
if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl))
|
||||
|
|
@ -153,15 +159,15 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
|
||||
switch (EpisodeType){
|
||||
case EpisodeType.MusicVideo:
|
||||
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||
await CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||
break;
|
||||
case EpisodeType.Concert:
|
||||
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||
await CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
|
||||
break;
|
||||
case EpisodeType.Episode:
|
||||
case EpisodeType.Unknown:
|
||||
default:
|
||||
await QueueManager.Instance.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
|
||||
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
|
||||
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang,
|
||||
CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode);
|
||||
break;
|
||||
|
|
@ -178,4 +184,4 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SonarrSeasonEpisodeText)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ public class HistorySeason : INotifyPropertyChanged{
|
|||
public StringItem? _selectedVideoQualityItem;
|
||||
|
||||
[JsonIgnore]
|
||||
private bool Loading = false;
|
||||
private bool Loading;
|
||||
|
||||
[JsonIgnore]
|
||||
public StringItem? SelectedVideoQualityItem{
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
public Bitmap? ThumbnailImage{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsImageLoaded{ get; private set; } = false;
|
||||
public bool IsImageLoaded{ get; private set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool FetchingData{ get; set; }
|
||||
|
|
@ -112,7 +112,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
#region Settings Override
|
||||
|
||||
[JsonIgnore]
|
||||
private bool Loading = false;
|
||||
private bool Loading;
|
||||
|
||||
[JsonIgnore]
|
||||
public StringItem? _selectedVideoQualityItem;
|
||||
|
|
@ -350,6 +350,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
break;
|
||||
case SeriesType.Movie:
|
||||
case SeriesType.Series:
|
||||
case SeriesType.Unknown:
|
||||
default:
|
||||
|
|
@ -396,6 +397,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
case SeriesType.Artist:
|
||||
Helpers.OpenUrl($"https://www.crunchyroll.com/artist/{SeriesId}");
|
||||
break;
|
||||
case SeriesType.Movie:
|
||||
case SeriesType.Series:
|
||||
case SeriesType.Unknown:
|
||||
default:
|
||||
|
|
@ -464,4 +466,4 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using CRD.Downloader;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Notifications;
|
||||
using Newtonsoft.Json;
|
||||
using NuGet.Versioning;
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ public class Updater : ObservableObject{
|
|||
public double Progress;
|
||||
public bool Failed;
|
||||
public string LatestVersion = "";
|
||||
public List<GithubJson> GhAuthJson = [];
|
||||
|
||||
public static Updater Instance{ get; } = new();
|
||||
|
||||
|
|
@ -109,12 +111,19 @@ public class Updater : ObservableObject{
|
|||
}
|
||||
|
||||
downloadUrl = asset.BrowserDownloadUrl;
|
||||
await NotificationPublisher.Instance.PublishUpdateAvailableAsync(
|
||||
CrunchyrollManager.Instance.CrunOptions.NotificationSettings,
|
||||
currentVersion.ToString(),
|
||||
selectedRelease.TagName,
|
||||
platformName,
|
||||
downloadUrl);
|
||||
|
||||
_ = UpdateChangelogAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.WriteLine("No updates available.");
|
||||
NotificationPublisher.Instance.ResetUpdateAvailableNotification();
|
||||
_ = UpdateChangelogAsync();
|
||||
return false;
|
||||
}
|
||||
|
|
@ -123,6 +132,25 @@ public class Updater : ObservableObject{
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckGhJsonAsync(){
|
||||
var url = "https://Crunchy-DL.github.io/Crunchy-Downloader/data.json";
|
||||
try{
|
||||
HttpClientHandler handler = new HttpClientHandler();
|
||||
handler.UseProxy = false;
|
||||
|
||||
using (var client = new HttpClient(handler)){
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
|
||||
var response = await client.GetStringAsync(url);
|
||||
var authList = Helpers.Deserialize<List<GithubJson>>(response, null);
|
||||
if (authList is{ Count: > 0 }){
|
||||
GhAuthJson = authList;
|
||||
}
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine("Failed to get GH CR Auth information");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateChangelogAsync(){
|
||||
var client = HttpClientReq.Instance.GetHttpClient();
|
||||
|
|
@ -138,7 +166,15 @@ public class Updater : ObservableObject{
|
|||
LatestVersion = "v1.0.0";
|
||||
}
|
||||
|
||||
if (existingVersion == LatestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(LatestVersion.TrimStart('v'))){
|
||||
if (!NuGetVersion.TryParse(existingVersion.TrimStart('v'), out var existingNuGetVersion)){
|
||||
existingNuGetVersion = NuGetVersion.Parse("1.0.0");
|
||||
}
|
||||
|
||||
if (!NuGetVersion.TryParse(LatestVersion.TrimStart('v'), out var latestNuGetVersion)){
|
||||
latestNuGetVersion = NuGetVersion.Parse("1.0.0");
|
||||
}
|
||||
|
||||
if (existingNuGetVersion >= latestNuGetVersion){
|
||||
Console.WriteLine("CHANGELOG.md is already up to date.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -180,10 +216,16 @@ public class Updater : ObservableObject{
|
|||
return string.Empty;
|
||||
|
||||
string[] lines = File.ReadAllLines(changelogFilePath);
|
||||
|
||||
foreach (string line in lines){
|
||||
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]");
|
||||
if (match.Success)
|
||||
return match.Groups[1].Value;
|
||||
Match match = Regex.Match(line, @"^## \[(v?[^\]]+)\]");
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
string versionText = match.Groups[1].Value;
|
||||
|
||||
if (NuGetVersion.TryParse(versionText.TrimStart('v'), out _))
|
||||
return versionText;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
|
|
@ -312,6 +354,18 @@ public class Updater : ObservableObject{
|
|||
OnPropertyChanged(nameof(Failed));
|
||||
}
|
||||
}
|
||||
|
||||
public class GithubJson{
|
||||
[JsonProperty("type")]
|
||||
public string Type{ get; set; } = string.Empty;
|
||||
[JsonProperty("version_name")]
|
||||
public string VersionName{ get; set; } = string.Empty;
|
||||
[JsonProperty("version_code")]
|
||||
public string VersionCode{ get; set; } = string.Empty;
|
||||
[JsonProperty("Authorization")]
|
||||
public string Authorization{ get; set; } = string.Empty;
|
||||
|
||||
}
|
||||
|
||||
public class GithubRelease{
|
||||
[JsonProperty("tag_name")]
|
||||
|
|
@ -325,7 +379,7 @@ public class Updater : ObservableObject{
|
|||
|
||||
public bool Prerelease{ get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class GithubAsset{
|
||||
[JsonProperty("url")]
|
||||
public string Url{ get; set; } = "";
|
||||
|
|
@ -365,12 +419,10 @@ public class Updater : ObservableObject{
|
|||
|
||||
[JsonProperty("browser_download_url")]
|
||||
public string BrowserDownloadUrl{ get; set; } = "";
|
||||
|
||||
|
||||
|
||||
|
||||
public bool IsForPlatform(string platform){
|
||||
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ using CRD.Utils.UI;
|
|||
using CRD.ViewModels.Utils;
|
||||
using CRD.Views.Utils;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class AccountPageViewModel : ViewModelBase{
|
||||
public partial class AccountPageViewModel : ViewModelBase, IDisposable{
|
||||
[ObservableProperty]
|
||||
private Bitmap? _profileImage;
|
||||
|
||||
|
|
@ -32,7 +31,9 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private string _remainingTime = "";
|
||||
|
||||
private static DispatcherTimer? _timer;
|
||||
private static AccountPageViewModel? _activeInstance;
|
||||
|
||||
private readonly DispatcherTimer _timer;
|
||||
private DateTime _targetTime;
|
||||
|
||||
private bool IsCancelled;
|
||||
|
|
@ -40,6 +41,14 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
private bool EndedButMaybeActive;
|
||||
|
||||
public AccountPageViewModel(){
|
||||
_activeInstance?.StopSubscriptionTimer();
|
||||
_activeInstance = this;
|
||||
|
||||
_timer = new DispatcherTimer{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_timer.Tick += Timer_Tick;
|
||||
|
||||
UpdatetProfile();
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +56,7 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
var remaining = _targetTime - DateTime.Now;
|
||||
if (remaining <= TimeSpan.Zero){
|
||||
RemainingTime = "No active Subscription";
|
||||
_timer?.Stop();
|
||||
_timer.Stop();
|
||||
if (UnknownEndDate){
|
||||
RemainingTime = "Unknown Subscription end date";
|
||||
}
|
||||
|
|
@ -55,30 +64,32 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
if (EndedButMaybeActive){
|
||||
RemainingTime = "Subscription maybe ended";
|
||||
}
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
|
||||
}
|
||||
} else{
|
||||
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdatetProfile(){
|
||||
|
||||
StopSubscriptionTimer();
|
||||
IsCancelled = false;
|
||||
UnknownEndDate = false;
|
||||
EndedButMaybeActive = false;
|
||||
RemainingTime = "No active Subscription";
|
||||
|
||||
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
|
||||
var firstEndpointProfile = firstEndpoint.Profile;
|
||||
|
||||
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
|
||||
|
||||
var isLoggedIn = firstEndpointProfile.Username != "???";
|
||||
HasMultiProfile = isLoggedIn && firstEndpoint.MultiProfile.Profiles.Count > 1;
|
||||
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
|
||||
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
|
||||
LoginLogoutText = isLoggedIn ? "Logout" : "Login"; // Default state
|
||||
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
|
||||
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
|
||||
|
||||
|
||||
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
|
||||
|
||||
if (subscriptions != null){
|
||||
if (subscriptions != null && HasSubscriptionData(subscriptions)){
|
||||
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
|
||||
var sub = subscriptions.SubscriptionProducts.First();
|
||||
IsCancelled = sub.IsCancelled;
|
||||
|
|
@ -97,23 +108,8 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
|
||||
if (!UnknownEndDate){
|
||||
_targetTime = subscriptions.NextRenewalDate;
|
||||
_timer = new DispatcherTimer{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_timer.Tick += Timer_Tick;
|
||||
_timer.Start();
|
||||
}
|
||||
} else{
|
||||
RemainingTime = "No active Subscription";
|
||||
if (_timer != null){
|
||||
_timer.Stop();
|
||||
_timer.Tick -= Timer_Tick;
|
||||
}
|
||||
|
||||
RaisePropertyChanged(nameof(RemainingTime));
|
||||
|
||||
if (subscriptions != null){
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
|
||||
Timer_Tick(null, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +122,26 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
}
|
||||
}
|
||||
|
||||
private static bool HasSubscriptionData(CRD.Utils.Structs.Crunchyroll.Subscription subscriptions){
|
||||
return subscriptions.SubscriptionProducts is{ Count: > 0 } ||
|
||||
subscriptions.ThirdPartySubscriptionProducts is{ Count: > 0 } ||
|
||||
subscriptions.NonrecurringSubscriptionProducts is{ Count: > 0 } ||
|
||||
subscriptions.FunimationSubscriptions is{ Count: > 0 };
|
||||
}
|
||||
|
||||
private void StopSubscriptionTimer(){
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
public void Dispose(){
|
||||
_timer.Tick -= Timer_Tick;
|
||||
StopSubscriptionTimer();
|
||||
|
||||
if (ReferenceEquals(_activeInstance, this)){
|
||||
_activeInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task Button_Press(){
|
||||
if (LoginLogoutText == "Login"){
|
||||
|
|
@ -191,4 +207,4 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
Console.Error.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,14 +207,14 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
|||
|
||||
if (music != null){
|
||||
var meta = musicClass.EpisodeMeta(music);
|
||||
QueueManager.Instance.CrAddMusicMetaToQueue(meta);
|
||||
CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
|
||||
}
|
||||
}
|
||||
} else if (AddAllEpisodes){
|
||||
var musicClass = CrunchyrollManager.Instance.CrMusic;
|
||||
if (currentMusicVideoList == null) return;
|
||||
foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){
|
||||
QueueManager.Instance.CrAddMusicMetaToQueue(meta);
|
||||
CrunchyrollManager.Instance.CrQueue.CrAddMusicMetaToQueue(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +223,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
|||
AddItemsToSelectedEpisodes();
|
||||
|
||||
if (currentSeriesList != null){
|
||||
await QueueManager.Instance.CrAddSeriesToQueue(
|
||||
await CrunchyrollManager.Instance.CrQueue.CrAddSeriesToQueue(
|
||||
currentSeriesList,
|
||||
new CrunchyMultiDownload(
|
||||
CrunchyrollManager.Instance.CrunOptions.DubLang,
|
||||
|
|
@ -327,17 +327,17 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private void HandleMusicVideoUrl(string id){
|
||||
_ = QueueManager.Instance.CrAddMusicVideoToQueue(id);
|
||||
_ = CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(id);
|
||||
ResetState();
|
||||
}
|
||||
|
||||
private void HandleConcertUrl(string id){
|
||||
_ = QueueManager.Instance.CrAddConcertToQueue(id);
|
||||
_ = CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(id);
|
||||
ResetState();
|
||||
}
|
||||
|
||||
private void HandleEpisodeUrl(string locale, string id){
|
||||
_ = QueueManager.Instance.CrAddEpisodeToQueue(
|
||||
_ = CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
|
||||
id, DetermineLocale(locale),
|
||||
CrunchyrollManager.Instance.CrunOptions.DubLang, true);
|
||||
ResetState();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -20,16 +21,16 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
|||
public ObservableCollection<DownloadItemModel> Items{ get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _shutdownWhenQueueEmpty;
|
||||
private bool shutdownWhenQueueEmpty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _autoDownload;
|
||||
private bool autoDownload;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _removeFinished;
|
||||
private bool removeFinished;
|
||||
|
||||
[ObservableProperty]
|
||||
private QueueManager _queueManagerIns;
|
||||
private QueueManager queueManagerIns;
|
||||
|
||||
public DownloadsPageViewModel(){
|
||||
QueueManagerIns = QueueManager.Instance;
|
||||
|
|
@ -63,10 +64,10 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public void ClearQueue(){
|
||||
var items = QueueManagerIns.Queue;
|
||||
QueueManagerIns.Queue.Clear();
|
||||
QueueManagerIns.ClearQueue();
|
||||
|
||||
foreach (var crunchyEpMeta in items){
|
||||
if (!crunchyEpMeta.DownloadProgress.Done){
|
||||
if (!crunchyEpMeta.DownloadProgress.IsDone){
|
||||
foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){
|
||||
try{
|
||||
if (File.Exists(downloadItemDownloadedFile)){
|
||||
|
|
@ -85,13 +86,26 @@ public partial class DownloadsPageViewModel : ViewModelBase{
|
|||
var items = QueueManagerIns.Queue;
|
||||
|
||||
foreach (var crunchyEpMeta in items){
|
||||
if (crunchyEpMeta.DownloadProgress.Error){
|
||||
crunchyEpMeta.DownloadProgress = new();
|
||||
if (crunchyEpMeta.DownloadProgress.IsError){
|
||||
crunchyEpMeta.DownloadProgress.ResetForRetry();
|
||||
}
|
||||
}
|
||||
|
||||
QueueManagerIns.UpdateDownloadListItems();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void PauseQueue(){
|
||||
AutoDownload = false;
|
||||
foreach (var item in Items){
|
||||
if (item.epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
|
||||
item.ToggleIsDownloading();
|
||||
}
|
||||
}
|
||||
|
||||
QueueManagerIns.UpdateDownloadListItems();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||
|
|
@ -113,6 +127,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
|
||||
|
||||
public bool Error{ get; set; }
|
||||
public bool ShowPauseIcon{ get; set; }
|
||||
|
||||
public DownloadItemModel(CrunchyEpMeta epMetaF){
|
||||
epMeta = epMetaF;
|
||||
|
|
@ -121,20 +136,14 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
Title = epMeta.SeriesTitle + (!string.IsNullOrEmpty(epMeta.Season) ? " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) : "") + " - " +
|
||||
epMeta.EpisodeTitle;
|
||||
|
||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
||||
|
||||
Done = epMeta.DownloadProgress.Done;
|
||||
Done = epMeta.DownloadProgress.IsDone;
|
||||
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||
ShowPauseIcon = isDownloading;
|
||||
Percent = epMeta.DownloadProgress.Percent;
|
||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
||||
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
|
||||
;
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" :
|
||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
Time = GetTimeText();
|
||||
DownloadSpeed = GetDownloadSpeedText();
|
||||
Paused = epMeta.DownloadProgress.IsPaused;
|
||||
DoingWhat = GetDoingWhatText();
|
||||
|
||||
InfoText = JoinWithSeparator(
|
||||
GetDubString(),
|
||||
|
|
@ -143,7 +152,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
);
|
||||
InfoTextHover = epMeta.AvailableQualities;
|
||||
|
||||
Error = epMeta.DownloadProgress.Error;
|
||||
Error = epMeta.DownloadProgress.IsError;
|
||||
}
|
||||
|
||||
string JoinWithSeparator(params string[] parts){
|
||||
|
|
@ -191,18 +200,15 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
public void Refresh(){
|
||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
||||
Done = epMeta.DownloadProgress.Done;
|
||||
Done = epMeta.DownloadProgress.IsDone;
|
||||
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||
ShowPauseIcon = isDownloading;
|
||||
Percent = epMeta.DownloadProgress.Percent;
|
||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
||||
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
Time = GetTimeText();
|
||||
DownloadSpeed = GetDownloadSpeedText();
|
||||
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" :
|
||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
Paused = epMeta.DownloadProgress.IsPaused;
|
||||
DoingWhat = GetDoingWhatText();
|
||||
|
||||
InfoText = JoinWithSeparator(
|
||||
GetDubString(),
|
||||
|
|
@ -210,11 +216,12 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
epMeta.Resolution
|
||||
);
|
||||
InfoTextHover = epMeta.AvailableQualities;
|
||||
Error = epMeta.DownloadProgress.Error;
|
||||
Error = epMeta.DownloadProgress.IsError;
|
||||
|
||||
|
||||
if (PropertyChanged != null){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
||||
|
|
@ -228,28 +235,90 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private string GetDoingWhatText(){
|
||||
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
|
||||
return "Rate limited, retrying at " + epMeta.DownloadProgress.RetryAtUtc.Value
|
||||
.ToLocalTime()
|
||||
.ToString("T", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
return Paused ? "Paused" :
|
||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
}
|
||||
|
||||
private string GetTimeText(){
|
||||
if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
|
||||
}
|
||||
|
||||
private string GetDownloadSpeedText(){
|
||||
if (epMeta.DownloadProgress.IsWaitingForRetry){
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
|
||||
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
|
||||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void ToggleIsDownloading(){
|
||||
if (isDownloading){
|
||||
//StopDownload();
|
||||
epMeta.Paused = !epMeta.Paused;
|
||||
if (epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){
|
||||
epMeta.DownloadProgress.ResumeState = epMeta.DownloadProgress.State;
|
||||
epMeta.DownloadProgress.State = DownloadState.Paused;
|
||||
isDownloading = false;
|
||||
Paused = true;
|
||||
ShowPauseIcon = false;
|
||||
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
} else{
|
||||
if (epMeta.Paused){
|
||||
epMeta.Paused = false;
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
} else{
|
||||
StartDownload();
|
||||
}
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
|
||||
QueueManager.Instance.ReleaseDownloadSlot(epMeta);
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (epMeta.DownloadProgress.IsPaused){
|
||||
if (!QueueManager.Instance.TryResumeDownload(epMeta))
|
||||
return;
|
||||
|
||||
if (PropertyChanged != null){
|
||||
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading"));
|
||||
epMeta.DownloadProgress.State = epMeta.DownloadProgress.ResumeState;
|
||||
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||
Paused = false;
|
||||
ShowPauseIcon = isDownloading;
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
return;
|
||||
}
|
||||
|
||||
StartDownload();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RetryDownload(){
|
||||
epMeta.DownloadProgress.ResetForRetry();
|
||||
isDownloading = false;
|
||||
Paused = false;
|
||||
ShowPauseIcon = false;
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
|
||||
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
StartDownload();
|
||||
}
|
||||
|
||||
public Task StartDownload(){
|
||||
|
|
@ -261,28 +330,34 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
if (isDownloading)
|
||||
return;
|
||||
|
||||
epMeta.RenewCancellationToken();
|
||||
isDownloading = true;
|
||||
epMeta.DownloadProgress.IsDownloading = true;
|
||||
epMeta.DownloadProgress.State = DownloadState.Downloading;
|
||||
Paused = false;
|
||||
ShowPauseIcon = true;
|
||||
|
||||
Paused = epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
|
||||
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
|
||||
if (epMeta.OnlySubs){
|
||||
newOptions.Novids = true;
|
||||
newOptions.Noaudio = true;
|
||||
newOptions?.Novids = true;
|
||||
newOptions?.Noaudio = true;
|
||||
}
|
||||
|
||||
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions);
|
||||
await CrunchyrollManager.Instance.DownloadEpisode(
|
||||
epMeta,
|
||||
epMeta.DownloadSettings ?? newOptions ?? CrunchyrollManager.Instance.CrunOptions);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RemoveFromQueue(){
|
||||
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
|
||||
if (downloadItem != null){
|
||||
QueueManager.Instance.Queue.Remove(downloadItem);
|
||||
epMeta.Cts.Cancel();
|
||||
QueueManager.Instance.RemoveFromQueue(downloadItem);
|
||||
epMeta.CancelDownload();
|
||||
if (!Done){
|
||||
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
|
||||
try{
|
||||
|
|
@ -301,4 +376,4 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
ImageBitmap = await Helpers.LoadImage(ImageUrl, 208, 117);
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _showArtists;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showMovies = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private static bool _viewSelectionOpen;
|
||||
|
||||
|
|
@ -160,6 +163,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
SortDir = properties?.Ascending ?? false;
|
||||
ShowSeries = properties?.ShowSeries ?? true;
|
||||
ShowArtists = properties?.ShowArtists ?? false;
|
||||
ShowMovies = properties?.ShowMovies ?? true;
|
||||
|
||||
foreach (HistoryViewType viewType in Enum.GetValues(typeof(HistoryViewType))){
|
||||
var combobox = new ComboBoxItem{ Content = viewType };
|
||||
|
|
@ -274,6 +278,14 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
ApplyFilter();
|
||||
}
|
||||
|
||||
partial void OnShowMoviesChanged(bool value){
|
||||
if (CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties != null) CrunchyrollManager.Instance.CrunOptions.HistoryPageProperties.ShowMovies = ShowMovies;
|
||||
|
||||
CfgManager.WriteCrSettings();
|
||||
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
|
||||
partial void OnSelectedFilterChanged(FilterListElement? value){
|
||||
if (value == null){
|
||||
|
|
@ -333,6 +345,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
|
||||
}
|
||||
|
||||
if (!ShowMovies){
|
||||
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Movie);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SearchInput)){
|
||||
var tokens = SearchInput
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
|
@ -662,6 +678,7 @@ public class HistoryPageProperties{
|
|||
|
||||
public bool ShowSeries{ get; set; } = true;
|
||||
public bool ShowArtists{ get; set; } = true;
|
||||
public bool ShowMovies{ get; set; } = true;
|
||||
}
|
||||
|
||||
public class SeasonsPageProperties{
|
||||
|
|
@ -678,4 +695,4 @@ public class SortingListElement{
|
|||
public class FilterListElement{
|
||||
public FilterType SelectedType{ get; set; }
|
||||
public string? FilterTitle{ get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
|
|
@ -7,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Utils.Ffmpeg_Encoding;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
using DynamicData;
|
||||
|
|
@ -19,61 +21,84 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
private readonly ContentDialog dialog;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _editMode;
|
||||
private bool editMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _presetName;
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private string presetName;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _codec;
|
||||
[NotifyPropertyChangedFor(nameof(HasCodec))]
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private string codec;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem _selectedResolution = new();
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private StringItemWithDisplayName selectedResolution = new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" };
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _crf = 23;
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private double? crf = 23;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _frameRate = "";
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private string frameRate = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _additionalParametersString = "";
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private string additionalParametersString = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<StringItem> _additionalParameters = new();
|
||||
[NotifyPropertyChangedFor(nameof(CommandPreview))]
|
||||
private ObservableCollection<StringItem> additionalParameters = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private VideoPreset? _selectedCustomPreset;
|
||||
private VideoPreset? selectedCustomPreset;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _fileExists;
|
||||
private bool fileExists;
|
||||
|
||||
public bool HasCodec => !string.IsNullOrWhiteSpace(Codec);
|
||||
public string CommandPreview => BuildCommandPreview();
|
||||
|
||||
public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
|
||||
|
||||
public ObservableCollection<ComboBoxItem> ResolutionList{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "3840:2160" }, // 4K UHD
|
||||
new ComboBoxItem(){ Content = "3440:1440" }, // Ultra-Wide Quad HD
|
||||
new ComboBoxItem(){ Content = "2560:1440" }, // 1440p
|
||||
new ComboBoxItem(){ Content = "2560:1080" }, // Ultra-Wide Full HD
|
||||
new ComboBoxItem(){ Content = "2160:1080" }, // 2:1 Aspect Ratio
|
||||
new ComboBoxItem(){ Content = "1920:1080" }, // 1080p Full HD
|
||||
new ComboBoxItem(){ Content = "1920:800" }, // Cinematic 2.40:1
|
||||
new ComboBoxItem(){ Content = "1600:900" }, // 900p
|
||||
new ComboBoxItem(){ Content = "1366:768" }, // 768p
|
||||
new ComboBoxItem(){ Content = "1280:960" }, // SXGA 4:3
|
||||
new ComboBoxItem(){ Content = "1280:720" }, // 720p HD
|
||||
new ComboBoxItem(){ Content = "1024:576" }, // 576p
|
||||
new ComboBoxItem(){ Content = "960:540" }, // 540p qHD
|
||||
new ComboBoxItem(){ Content = "854:480" }, // 480p
|
||||
new ComboBoxItem(){ Content = "800:600" }, // SVGA
|
||||
new ComboBoxItem(){ Content = "768:432" }, // 432p
|
||||
new ComboBoxItem(){ Content = "720:480" }, // NTSC SD
|
||||
new ComboBoxItem(){ Content = "704:576" }, // PAL SD
|
||||
new ComboBoxItem(){ Content = "640:360" }, // 360p
|
||||
new ComboBoxItem(){ Content = "426:240" }, // 240p
|
||||
new ComboBoxItem(){ Content = "320:240" }, // QVGA
|
||||
new ComboBoxItem(){ Content = "320:180" }, // 180p
|
||||
new ComboBoxItem(){ Content = "256:144" }, // 144p
|
||||
public ObservableCollection<StringItemWithDisplayName> ResolutionList{ get; } = new(){
|
||||
new(){ value = "3840:2160", DisplayName = "4K exact (3840:2160)" },
|
||||
new(){ value = "-2:2160", DisplayName = "4K keep AR (-2:2160)" },
|
||||
new(){ value = "3440:1440", DisplayName = "UWQHD exact (3440:1440)" },
|
||||
new(){ value = "2560:1440", DisplayName = "1440p exact (2560:1440)" },
|
||||
new(){ value = "-2:1440", DisplayName = "1440p keep AR (-2:1440)" },
|
||||
new(){ value = "2560:1080", DisplayName = "UW FHD exact (2560:1080)" },
|
||||
new(){ value = "2160:1080", DisplayName = "2:1 exact (2160:1080)" },
|
||||
new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" },
|
||||
new(){ value = "-2:1080", DisplayName = "1080p keep AR (-2:1080)" },
|
||||
new(){ value = "1920:800", DisplayName = "Cinema exact (1920:800)" },
|
||||
new(){ value = "1600:900", DisplayName = "900p exact (1600:900)" },
|
||||
new(){ value = "1366:768", DisplayName = "768p exact (1366:768)" },
|
||||
new(){ value = "1280:960", DisplayName = "SXGA exact (1280:960)" },
|
||||
new(){ value = "1280:720", DisplayName = "720p exact (1280:720)" },
|
||||
new(){ value = "-2:720", DisplayName = "720p keep AR (-2:720)" },
|
||||
new(){ value = "1024:576", DisplayName = "576p exact (1024:576)" },
|
||||
new(){ value = "-2:576", DisplayName = "576p keep AR (-2:576)" },
|
||||
new(){ value = "960:540", DisplayName = "540p exact (960:540)" },
|
||||
new(){ value = "-2:540", DisplayName = "540p keep AR (-2:540)" },
|
||||
new(){ value = "854:480", DisplayName = "480p exact (854:480)" },
|
||||
new(){ value = "-2:480", DisplayName = "480p keep AR (-2:480)" },
|
||||
new(){ value = "800:600", DisplayName = "SVGA exact (800:600)" },
|
||||
new(){ value = "768:432", DisplayName = "432p exact (768:432)" },
|
||||
new(){ value = "-2:432", DisplayName = "432p keep AR (-2:432)" },
|
||||
new(){ value = "720:480", DisplayName = "NTSC exact (720:480)" },
|
||||
new(){ value = "704:576", DisplayName = "PAL exact (704:576)" },
|
||||
new(){ value = "640:360", DisplayName = "360p exact (640:360)" },
|
||||
new(){ value = "-2:360", DisplayName = "360p keep AR (-2:360)" },
|
||||
new(){ value = "426:240", DisplayName = "240p exact (426:240)" },
|
||||
new(){ value = "-2:240", DisplayName = "240p keep AR (-2:240)" },
|
||||
new(){ value = "320:240", DisplayName = "QVGA exact (320:240)" },
|
||||
new(){ value = "320:180", DisplayName = "180p exact (320:180)" },
|
||||
new(){ value = "-2:180", DisplayName = "180p keep AR (-2:180)" },
|
||||
new(){ value = "256:144", DisplayName = "144p exact (256:144)" },
|
||||
new(){ value = "-2:144", DisplayName = "144p keep AR (-2:144)" },
|
||||
};
|
||||
|
||||
public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){
|
||||
|
|
@ -84,6 +109,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" });
|
||||
AdditionalParameters.CollectionChanged += AdditionalParametersOnCollectionChanged;
|
||||
|
||||
if (editMode){
|
||||
EditMode = true;
|
||||
|
|
@ -108,9 +134,9 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
PresetName = value.PresetName ?? "";
|
||||
Codec = value.Codec ?? "";
|
||||
Crf = value.Crf;
|
||||
FrameRate = value.FrameRate ?? "24";
|
||||
FrameRate = value.FrameRate ?? "24000/1001";
|
||||
|
||||
SelectedResolution = ResolutionList.FirstOrDefault(e => e.Content?.ToString() == value.Resolution) ?? ResolutionList.First();
|
||||
SelectedResolution = ResolutionList.FirstOrDefault(e => e.value == value.Resolution) ?? ResolutionList.First();
|
||||
AdditionalParameters.Clear();
|
||||
|
||||
foreach (var valueAdditionalParameter in value.AdditionalParameters){
|
||||
|
|
@ -133,13 +159,36 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
public void AddAdditionalParam(){
|
||||
AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString });
|
||||
AdditionalParametersString = "";
|
||||
RaisePropertyChanged(nameof(AdditionalParametersString));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RemoveAdditionalParam(StringItem param){
|
||||
AdditionalParameters.Remove(param);
|
||||
RaisePropertyChanged(nameof(AdditionalParameters));
|
||||
}
|
||||
|
||||
private void AdditionalParametersOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e){
|
||||
OnPropertyChanged(nameof(CommandPreview));
|
||||
}
|
||||
|
||||
private string BuildCommandPreview(){
|
||||
var previewPreset = new VideoPreset{
|
||||
PresetName = PresetName,
|
||||
Codec = Codec,
|
||||
FrameRate = string.IsNullOrWhiteSpace(FrameRate) ? "24000/1001" : FrameRate,
|
||||
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
|
||||
Resolution = SelectedResolution.value,
|
||||
AdditionalParameters = AdditionalParameters
|
||||
.Select(additionalParameter => additionalParameter.stringValue)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var args = Helpers.BuildFFmpegArgsForPreset(
|
||||
"S01E01.mkv",
|
||||
previewPreset,
|
||||
"S01E01_output.mkv");
|
||||
|
||||
return Helpers.BuildCommandString("ffmpeg", args);
|
||||
}
|
||||
|
||||
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
||||
|
|
@ -153,7 +202,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
SelectedCustomPreset.Codec = Codec;
|
||||
SelectedCustomPreset.FrameRate = FrameRate;
|
||||
SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51);
|
||||
SelectedCustomPreset.Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080";
|
||||
SelectedCustomPreset.Resolution = SelectedResolution.value;
|
||||
SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList();
|
||||
|
||||
try{
|
||||
|
|
@ -175,7 +224,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
Codec = Codec,
|
||||
FrameRate = FrameRate,
|
||||
Crf = Math.Clamp((int)(Crf ?? 0), 0, 51),
|
||||
Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080",
|
||||
Resolution = SelectedResolution.value,
|
||||
AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList()
|
||||
};
|
||||
|
||||
|
|
@ -186,6 +235,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
|
||||
AdditionalParameters.CollectionChanged -= AdditionalParametersOnCollectionChanged;
|
||||
dialog.Closed -= DialogOnClosed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,15 +20,20 @@ using CRD.Downloader.Crunchyroll;
|
|||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Notifications;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Views;
|
||||
using FluentAvalonia.Styling;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.ViewModels.Utils;
|
||||
|
||||
public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||
private readonly AudioPlayer notificationTestPlayer = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private string currentVersion;
|
||||
|
||||
|
|
@ -43,6 +48,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
[ObservableProperty]
|
||||
private bool historyIncludeCrArtists;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool historyRemoveMissingEpisodes;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool historyAddSpecials;
|
||||
|
|
@ -59,6 +67,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private HistoryRefreshMode historyAutoRefreshMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool historyAutoRefreshAddToQueue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string historyAutoRefreshModeHint;
|
||||
|
||||
|
|
@ -86,6 +97,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool downloadAllowEarlyStart;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool persistQueue;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? downloadSpeed;
|
||||
|
||||
|
|
@ -97,6 +111,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
[ObservableProperty]
|
||||
private double? retryDelay;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? playbackRateLimitRetryDelaySeconds;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? retryMaxDelaySeconds;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool trayIconEnabled;
|
||||
|
|
@ -285,9 +305,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private string downloadFinishedExecutePath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private string webhookUrl = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string webhookMethod = "POST";
|
||||
|
||||
[ObservableProperty]
|
||||
private string webhookContentType = "application/json";
|
||||
|
||||
[ObservableProperty]
|
||||
private string webhookHeadersText = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string webhookBodyTemplate = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyQueueFinished;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyDownloadFinished;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyDownloadFailed;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyTrackedSeriesEpisodeReleased;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyLoginExpired;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool webhookNotifyUpdateAvailable;
|
||||
|
||||
[ObservableProperty]
|
||||
private string currentIp = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTestingFinishedSound;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isTestingWebhook;
|
||||
|
||||
private readonly FluentAvaloniaTheme faTheme;
|
||||
|
||||
private bool settingsLoaded;
|
||||
|
|
@ -312,16 +374,29 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions;
|
||||
options.NormalizeNotificationSettings();
|
||||
|
||||
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
|
||||
BackgroundImageOpacity = options.BackgroundImageOpacity;
|
||||
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
|
||||
|
||||
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
|
||||
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
|
||||
var soundProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||
DownloadFinishedSoundPath = soundProvider?.Path ?? string.Empty;
|
||||
DownloadFinishedPlaySound = soundProvider?.Enabled ?? false;
|
||||
|
||||
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
|
||||
DownloadFinishedExecute = options.DownloadFinishedExecute;
|
||||
var executeProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||
DownloadFinishedExecutePath = executeProvider?.Path ?? string.Empty;
|
||||
DownloadFinishedExecute = executeProvider?.Enabled ?? false;
|
||||
|
||||
var webhookProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Webhook);
|
||||
WebhookEnabled = webhookProvider?.Enabled ?? false;
|
||||
WebhookUrl = webhookProvider?.Url ?? string.Empty;
|
||||
WebhookMethod = string.IsNullOrWhiteSpace(webhookProvider?.Method) ? "POST" : webhookProvider.Method;
|
||||
WebhookContentType = string.IsNullOrWhiteSpace(webhookProvider?.ContentType) ? "application/json" : webhookProvider.ContentType;
|
||||
WebhookHeadersText = SerializeHeaders(webhookProvider?.Headers);
|
||||
WebhookBodyTemplate = webhookProvider?.BodyTemplate ?? string.Empty;
|
||||
|
||||
LoadProviderEvents(webhookProvider, NotificationProviderType.Webhook);
|
||||
|
||||
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
|
||||
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
|
||||
|
|
@ -365,19 +440,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
ProxyPort = options.ProxyPort;
|
||||
HistoryCountMissing = options.HistoryCountMissing;
|
||||
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
|
||||
HistoryRemoveMissingEpisodes = options.HistoryRemoveMissingEpisodes;
|
||||
HistoryAddSpecials = options.HistoryAddSpecials;
|
||||
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
|
||||
HistoryCountSonarr = options.HistoryCountSonarr;
|
||||
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
|
||||
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
|
||||
HistoryAutoRefreshAddToQueue = options.HistoryAutoRefreshAddToQueue;
|
||||
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
DownloadSpeed = options.DownloadSpeedLimit;
|
||||
DownloadSpeedInBits = options.DownloadSpeedInBits;
|
||||
DownloadMethodeNew = options.DownloadMethodeNew;
|
||||
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
|
||||
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
|
||||
PersistQueue = options.PersistQueue;
|
||||
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
|
||||
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
|
||||
PlaybackRateLimitRetryDelaySeconds = Math.Clamp(options.PlaybackRateLimitRetryDelaySeconds, 1, 86400);
|
||||
RetryMaxDelaySeconds = Math.Clamp(options.RetryMaxDelaySeconds, 1, 86400);
|
||||
DownloadToTempFolder = options.DownloadToTempFolder;
|
||||
SimultaneousDownloads = options.SimultaneousDownloads;
|
||||
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
|
||||
|
|
@ -417,34 +497,65 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||
|
||||
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
|
||||
|
||||
settings.DownloadFinishedExecute = DownloadFinishedExecute;
|
||||
settings.NotificationSettings ??= new NotificationSettings();
|
||||
|
||||
var soundProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound);
|
||||
soundProvider.Enabled = DownloadFinishedPlaySound;
|
||||
soundProvider.Path = DownloadFinishedSoundPath;
|
||||
soundProvider.Events = [NotificationEventType.QueueFinished];
|
||||
|
||||
var executeProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute);
|
||||
executeProvider.Enabled = DownloadFinishedExecute;
|
||||
executeProvider.Path = DownloadFinishedExecutePath;
|
||||
executeProvider.Events = [NotificationEventType.QueueFinished];
|
||||
|
||||
var webhookProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Webhook);
|
||||
webhookProvider.Enabled = WebhookEnabled;
|
||||
webhookProvider.Url = WebhookUrl?.Trim() ?? string.Empty;
|
||||
webhookProvider.Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant();
|
||||
webhookProvider.ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim();
|
||||
webhookProvider.Headers = ParseHeaders(WebhookHeadersText);
|
||||
webhookProvider.BodyTemplate = WebhookBodyTemplate ?? string.Empty;
|
||||
webhookProvider.Events = BuildEvents(
|
||||
WebhookNotifyQueueFinished,
|
||||
WebhookNotifyDownloadFinished,
|
||||
WebhookNotifyDownloadFailed,
|
||||
WebhookNotifyTrackedSeriesEpisodeReleased,
|
||||
WebhookNotifyLoginExpired,
|
||||
WebhookNotifyUpdateAvailable
|
||||
);
|
||||
|
||||
settings.SyncLegacyNotificationFields();
|
||||
|
||||
settings.DownloadMethodeNew = DownloadMethodeNew;
|
||||
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
|
||||
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
|
||||
settings.PersistQueue = PersistQueue;
|
||||
|
||||
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
|
||||
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
|
||||
|
||||
settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10);
|
||||
settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30);
|
||||
settings.PlaybackRateLimitRetryDelaySeconds = Math.Clamp((int)(PlaybackRateLimitRetryDelaySeconds ?? 0), 1, 86400);
|
||||
settings.RetryMaxDelaySeconds = Math.Clamp((int)(RetryMaxDelaySeconds ?? 0), 1, 86400);
|
||||
|
||||
settings.DownloadToTempFolder = DownloadToTempFolder;
|
||||
settings.HistoryCountMissing = HistoryCountMissing;
|
||||
settings.HistoryAddSpecials = HistoryAddSpecials;
|
||||
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
|
||||
settings.HistoryRemoveMissingEpisodes = HistoryRemoveMissingEpisodes;
|
||||
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
|
||||
settings.HistoryCountSonarr = HistoryCountSonarr;
|
||||
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
|
||||
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
|
||||
settings.HistoryAutoRefreshAddToQueue = HistoryAutoRefreshAddToQueue;
|
||||
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
|
||||
settings.DownloadSpeedInBits = DownloadSpeedInBits;
|
||||
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
|
||||
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
|
||||
|
||||
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs);
|
||||
QueueManager.Instance.SetProcessingLimit(settings.SimultaneousProcessingJobs);
|
||||
|
||||
settings.ProxyEnabled = ProxyEnabled;
|
||||
settings.ProxySocks = ProxySocks;
|
||||
|
|
@ -519,6 +630,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
settings.LogMode = LogMode;
|
||||
|
||||
CfgManager.WriteCrSettings();
|
||||
|
||||
if (!PersistQueue){
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -613,7 +728,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
[RelayCommand]
|
||||
public void ClearFinishedSoundPath(){
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = string.Empty;
|
||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||
settings.NotificationSettings ??= new NotificationSettings();
|
||||
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = string.Empty;
|
||||
settings.SyncLegacyNotificationFields();
|
||||
DownloadFinishedSoundPath = string.Empty;
|
||||
}
|
||||
|
||||
|
|
@ -627,21 +745,131 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
},
|
||||
pathSetter: (path) => {
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
|
||||
var validationResult = AudioPlayer.ValidateSoundFile(path);
|
||||
if (!validationResult.IsValid){
|
||||
MessageBus.Current.SendMessage(new ToastMessage(validationResult.ErrorMessage, ToastType.Error, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||
settings.NotificationSettings ??= new NotificationSettings();
|
||||
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = path;
|
||||
settings.SyncLegacyNotificationFields();
|
||||
DownloadFinishedSoundPath = path;
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Notification sound updated", ToastType.Information, 2));
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty,
|
||||
defaultPath: string.Empty
|
||||
);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task TestFinishedSoundAsync(){
|
||||
if (IsTestingFinishedSound){
|
||||
return;
|
||||
}
|
||||
|
||||
var path = CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty;
|
||||
IsTestingFinishedSound = true;
|
||||
|
||||
try{
|
||||
var result = await notificationTestPlayer.ValidatePlaybackAsync(path);
|
||||
|
||||
if (result.IsSuccess){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Notification sound test succeeded", ToastType.Information, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Notification sound test failed: {result.ErrorMessage}", ToastType.Error, 5));
|
||||
} finally{
|
||||
IsTestingFinishedSound = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task StopFinishedSoundAsync(){
|
||||
await notificationTestPlayer.StopAsync();
|
||||
IsTestingFinishedSound = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task TestWebhookAsync(){
|
||||
if (IsTestingWebhook){
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedEvents = BuildEvents(
|
||||
WebhookNotifyQueueFinished,
|
||||
WebhookNotifyDownloadFinished,
|
||||
WebhookNotifyDownloadFailed,
|
||||
WebhookNotifyTrackedSeriesEpisodeReleased,
|
||||
WebhookNotifyLoginExpired,
|
||||
WebhookNotifyUpdateAvailable
|
||||
);
|
||||
|
||||
if (!WebhookEnabled){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Enable the webhook first", ToastType.Error, 4));
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(WebhookUrl)){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Set a webhook URL first", ToastType.Error, 4));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEvents.Count == 0){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Select at least one webhook event to test", ToastType.Error, 4));
|
||||
return;
|
||||
}
|
||||
|
||||
IsTestingWebhook = true;
|
||||
|
||||
try{
|
||||
var settings = new NotificationSettings{
|
||||
Providers = [
|
||||
new NotificationProviderConfig{
|
||||
Type = NotificationProviderType.Webhook,
|
||||
Enabled = true,
|
||||
Url = WebhookUrl.Trim(),
|
||||
Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant(),
|
||||
ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim(),
|
||||
Headers = ParseHeaders(WebhookHeadersText),
|
||||
BodyTemplate = WebhookBodyTemplate ?? string.Empty,
|
||||
Events = selectedEvents
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var sentCount = 0;
|
||||
|
||||
foreach (var notificationEvent in BuildTestWebhookEvents(selectedEvents)){
|
||||
if (await NotificationDispatcher.Instance.PublishWithResultAsync(settings, notificationEvent)){
|
||||
sentCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sentCount == selectedEvents.Count){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} test webhook event(s)", ToastType.Information, 3));
|
||||
} else if (sentCount > 0){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} of {selectedEvents.Count} test webhook event(s)", ToastType.Error, 5));
|
||||
} else{
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Webhook test failed for all selected events", ToastType.Error, 5));
|
||||
}
|
||||
} finally{
|
||||
IsTestingWebhook = false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download Finished Execute File
|
||||
|
||||
[RelayCommand]
|
||||
public void ClearFinishedExectuePath(){
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
|
||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||
settings.NotificationSettings ??= new NotificationSettings();
|
||||
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = string.Empty;
|
||||
settings.SyncLegacyNotificationFields();
|
||||
DownloadFinishedExecutePath = string.Empty;
|
||||
}
|
||||
|
||||
|
|
@ -655,10 +883,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
},
|
||||
pathSetter: (path) => {
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
|
||||
var settings = CrunchyrollManager.Instance.CrunOptions;
|
||||
settings.NotificationSettings ??= new NotificationSettings();
|
||||
settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = path;
|
||||
settings.SyncLegacyNotificationFields();
|
||||
DownloadFinishedExecutePath = path;
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute).Path ?? string.Empty,
|
||||
defaultPath: string.Empty
|
||||
);
|
||||
}
|
||||
|
|
@ -692,6 +923,189 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
}
|
||||
|
||||
private void LoadProviderEvents(NotificationProviderConfig? provider, NotificationProviderType type){
|
||||
var events = provider?.Events ?? [];
|
||||
|
||||
switch (type){
|
||||
case NotificationProviderType.Webhook:
|
||||
WebhookNotifyQueueFinished = events.Contains(NotificationEventType.QueueFinished);
|
||||
WebhookNotifyDownloadFinished = events.Contains(NotificationEventType.DownloadFinished);
|
||||
WebhookNotifyDownloadFailed = events.Contains(NotificationEventType.DownloadFailed);
|
||||
WebhookNotifyTrackedSeriesEpisodeReleased = events.Contains(NotificationEventType.TrackedSeriesEpisodeReleased);
|
||||
WebhookNotifyLoginExpired = events.Contains(NotificationEventType.LoginExpired);
|
||||
WebhookNotifyUpdateAvailable = events.Contains(NotificationEventType.UpdateAvailable);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<NotificationEventType> BuildEvents(
|
||||
bool queueFinished,
|
||||
bool downloadFinished,
|
||||
bool downloadFailed,
|
||||
bool trackedSeriesEpisodeReleased,
|
||||
bool loginExpired,
|
||||
bool updateAvailable){
|
||||
var events = new List<NotificationEventType>();
|
||||
|
||||
if (queueFinished){
|
||||
events.Add(NotificationEventType.QueueFinished);
|
||||
}
|
||||
|
||||
if (downloadFinished){
|
||||
events.Add(NotificationEventType.DownloadFinished);
|
||||
}
|
||||
|
||||
if (downloadFailed){
|
||||
events.Add(NotificationEventType.DownloadFailed);
|
||||
}
|
||||
|
||||
if (trackedSeriesEpisodeReleased){
|
||||
events.Add(NotificationEventType.TrackedSeriesEpisodeReleased);
|
||||
}
|
||||
|
||||
if (loginExpired){
|
||||
events.Add(NotificationEventType.LoginExpired);
|
||||
}
|
||||
|
||||
if (updateAvailable){
|
||||
events.Add(NotificationEventType.UpdateAvailable);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static IEnumerable<NotificationEvent> BuildTestWebhookEvents(IEnumerable<NotificationEventType> selectedEvents){
|
||||
foreach (var eventType in selectedEvents.Distinct()){
|
||||
yield return BuildTestWebhookEvent(eventType);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationEvent BuildTestWebhookEvent(NotificationEventType eventType){
|
||||
return eventType switch{
|
||||
NotificationEventType.QueueFinished => new NotificationEvent{
|
||||
Type = NotificationEventType.QueueFinished,
|
||||
Title = "Downloads finished",
|
||||
Message = "All queued downloads have finished processing.",
|
||||
Metadata = []
|
||||
},
|
||||
NotificationEventType.DownloadFinished => new NotificationEvent{
|
||||
Type = NotificationEventType.DownloadFinished,
|
||||
Title = "Download finished",
|
||||
Message = "Finished processing Example Series.",
|
||||
Metadata = BuildTestDownloadMetadata()
|
||||
},
|
||||
NotificationEventType.DownloadFailed => new NotificationEvent{
|
||||
Type = NotificationEventType.DownloadFailed,
|
||||
Title = "Download failed",
|
||||
Message = "Failed to download Example Series: Example failure message",
|
||||
Metadata = BuildTestDownloadMetadata("Example failure message")
|
||||
},
|
||||
NotificationEventType.TrackedSeriesEpisodeReleased => new NotificationEvent{
|
||||
Type = NotificationEventType.TrackedSeriesEpisodeReleased,
|
||||
Title = "Tracked series episode released",
|
||||
Message = "A tracked episode is available for Example Series: Episode Title.",
|
||||
Metadata = new Dictionary<string, string>{
|
||||
["seriesTitle"] = "Example Series",
|
||||
["seriesId"] = "G6ABC1234",
|
||||
["seasonId"] = "G6SEASON01",
|
||||
["episodeTitle"] = "Episode Title",
|
||||
["episodeId"] = "G6EP0001",
|
||||
["episodeNumber"] = "1",
|
||||
["seasonNumber"] = "1",
|
||||
["releaseDate"] = DateTimeOffset.UtcNow.AddMinutes(-30).ToString("O"),
|
||||
["premiumAvailableDate"] = DateTimeOffset.UtcNow.ToString("O"),
|
||||
["episodeUrl"] = "https://www.crunchyroll.com/en-US/watch/G6EP0001/episode-title",
|
||||
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
|
||||
["description"] = "Example tracked-release description.",
|
||||
["durationMs"] = "1440000",
|
||||
["availableDubs"] = "en-US, ja-JP",
|
||||
["availableSubs"] = "en-US, de-DE"
|
||||
}
|
||||
},
|
||||
NotificationEventType.LoginExpired => new NotificationEvent{
|
||||
Type = NotificationEventType.LoginExpired,
|
||||
Title = "Crunchyroll login expired",
|
||||
Message = "The saved Crunchyroll session could not be refreshed. Please log in again.",
|
||||
Metadata = new Dictionary<string, string>{
|
||||
["username"] = "example-user",
|
||||
["endpoint"] = "/auth/v1/token"
|
||||
}
|
||||
},
|
||||
NotificationEventType.UpdateAvailable => new NotificationEvent{
|
||||
Type = NotificationEventType.UpdateAvailable,
|
||||
Title = "Update available",
|
||||
Message = "Version v9.9.9 is available. Current version: v1.0.0.",
|
||||
Metadata = new Dictionary<string, string>{
|
||||
["currentVersion"] = "v1.0.0",
|
||||
["latestVersion"] = "v9.9.9",
|
||||
["platform"] = "win-x64",
|
||||
["downloadUrl"] = "https://github.com/Crunchy-DL/Crunchy-Downloader/releases/latest"
|
||||
}
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildTestDownloadMetadata(string? error = null){
|
||||
var metadata = new Dictionary<string, string>{
|
||||
["seriesTitle"] = "Example Series",
|
||||
["seasonTitle"] = "Season 1",
|
||||
["episodeTitle"] = "Episode Title",
|
||||
["episodeNumber"] = "1",
|
||||
["episodeId"] = "G6EP0001",
|
||||
["downloadPath"] = @"C:\Downloads\Example Series\Season 1",
|
||||
["seasonNumber"] = "1",
|
||||
["description"] = "Example download description.",
|
||||
["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg",
|
||||
["imageUrlLarge"] = "https://static.crunchyroll.com/example-poster.jpg",
|
||||
["downloadSubs"] = "en-US, de-DE",
|
||||
["downloadDubs"] = "ja-JP",
|
||||
["hardsub"] = string.Empty,
|
||||
["seriesId"] = "G6ABC1234",
|
||||
["seasonId"] = "G6SEASON01",
|
||||
["episodeUrl"] = "https://www.crunchyroll.com/watch/G6EP0001"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error)){
|
||||
metadata["error"] = error;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static string SerializeHeaders(IReadOnlyDictionary<string, string>? headers){
|
||||
if (headers == null || headers.Count == 0){
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, headers.Select(pair => $"{pair.Key}: {pair.Value}"));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseHeaders(string? headerText){
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(headerText)){
|
||||
return headers;
|
||||
}
|
||||
|
||||
foreach (var rawLine in headerText.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)){
|
||||
var separatorIndex = rawLine.IndexOf(':');
|
||||
if (separatorIndex <= 0){
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = rawLine[..separatorIndex].Trim();
|
||||
var value = rawLine[(separatorIndex + 1)..].Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(key)){
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
|
||||
if (value?.Content?.ToString() == "System"){
|
||||
|
|
@ -758,7 +1172,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
or nameof(CurrentAppTheme)
|
||||
or nameof(UseCustomAccent)
|
||||
or nameof(TrayIconEnabled)
|
||||
or nameof(LogMode)){
|
||||
or nameof(LogMode)
|
||||
or nameof(PersistQueue)){
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -829,4 +1244,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
CfgManager.DisableLogMode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPersistQueueChanged(bool value){
|
||||
UpdateSettings();
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,20 @@
|
|||
<TextBlock Text="Retry failed" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
|
||||
</ToolTip.Tip>
|
||||
</Button>
|
||||
|
||||
<Button BorderThickness="0"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0 0 10 0 "
|
||||
VerticalAlignment="Center"
|
||||
IsEnabled="{Binding QueueManagerIns.HasActiveDownloads}"
|
||||
Command="{Binding PauseQueue}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<controls:SymbolIcon Symbol="Pause" FontSize="22" />
|
||||
</StackPanel>
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Pause running" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
|
||||
</ToolTip.Tip>
|
||||
</Button>
|
||||
|
||||
<Button BorderThickness="0"
|
||||
HorizontalAlignment="Right"
|
||||
|
|
@ -108,11 +122,11 @@
|
|||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="{Binding
|
||||
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" />
|
||||
ShowPauseIcon, Converter={StaticResource UiValueConverter}}" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic"
|
||||
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding RetryDownload}" FontStyle="Italic"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
||||
|
|
@ -158,4 +172,4 @@
|
|||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -257,6 +257,16 @@
|
|||
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowArtists}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="8 0 5 0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Text="Movies" Grid.Column="0" VerticalAlignment="Center" />
|
||||
<ToggleSwitch OffContent="" OnContent="" Grid.Column="1" IsChecked="{Binding ShowMovies}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
|
||||
<Rectangle Height="1" Fill="Gray" Margin="0,8,0,8" />
|
||||
|
||||
|
|
@ -1272,4 +1282,4 @@
|
|||
</Grid>
|
||||
|
||||
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -38,29 +38,43 @@
|
|||
<StackPanel>
|
||||
<TextBlock Text="Enter Codec" Margin="0,10,0,5" />
|
||||
<TextBox Watermark="libx265" Text="{Binding Codec}" />
|
||||
<TextBlock Text="Leave empty to provide the encoding options through Additional Parameters only."
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasCodec}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Resolution ComboBox -->
|
||||
<StackPanel>
|
||||
<StackPanel IsVisible="{Binding HasCodec}">
|
||||
<TextBlock Text="Select Resolution" Margin="0,10,0,5" />
|
||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||
ItemsSource="{Binding ResolutionList}"
|
||||
SelectedItem="{Binding SelectedResolution}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayName}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Text="Use exact sizes for fixed output dimensions, or keep AR to preserve the source aspect ratio."
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Frame Rate NumberBox -->
|
||||
<StackPanel>
|
||||
<StackPanel IsVisible="{Binding HasCodec}">
|
||||
<TextBlock Text="Enter Frame Rate" Margin="0,10,0,5" />
|
||||
<!-- <controls:NumberBox Minimum="1" Maximum="999" -->
|
||||
<!-- Value="{Binding FrameRate}" -->
|
||||
<!-- SpinButtonPlacementMode="Inline" -->
|
||||
<!-- HorizontalAlignment="Stretch" /> -->
|
||||
<TextBox Watermark="24" Text="{Binding FrameRate}" />
|
||||
<TextBox Watermark="24000/1001" Text="{Binding FrameRate}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- CRF NumberBox -->
|
||||
<StackPanel>
|
||||
<StackPanel IsVisible="{Binding HasCodec}">
|
||||
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
|
||||
<controls:NumberBox Minimum="0" Maximum="51"
|
||||
Value="{Binding Crf}"
|
||||
|
|
@ -109,7 +123,26 @@
|
|||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,20,0,0">
|
||||
<TextBlock Text="Generated FFmpeg Command" Margin="0,0,0,5" />
|
||||
<Border BorderBrush="#4a4a4a"
|
||||
Background="{DynamicResource ControlAltFillColorQuarternary}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10"
|
||||
MaxWidth="700"
|
||||
HorizontalAlignment="Left">
|
||||
<SelectableTextBlock Text="{Binding CommandPreview}"
|
||||
FontFamily="Cascadia Mono, Consolas, Courier New"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<TextBlock Text="This preview uses sample input and output file names, but the generated options match the preset."
|
||||
Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@
|
|||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Remove Missing History Episodes" Description="During history refresh, remove episodes no longer available on the streaming service. Seasons with no episodes left are removed too.">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding HistoryRemoveMissingEpisodes}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue/count if they weren't downloaded before">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
|
||||
|
|
@ -91,6 +97,13 @@
|
|||
Text="{Binding HistoryAutoRefreshModeHint}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<CheckBox IsChecked="{Binding HistoryAutoRefreshAddToQueue}"
|
||||
Content="Add newly found missing episodes to the queue" />
|
||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
|
||||
Text="When disabled, auto refresh only updates history and missing counts." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
|
||||
</StackPanel>
|
||||
|
|
@ -107,6 +120,12 @@
|
|||
Description="Adjust download settings"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Persist queue" Description="Save the current download queue on exit and restore it on the next start">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding PersistQueue}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Enable New Download Method" Description="Enables the updated download handling logic. This may improve performance and stability.">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
|
||||
|
|
@ -146,19 +165,33 @@
|
|||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Width="100" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<TextBlock Width="200" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<controls:NumberBox Minimum="1" Maximum="10"
|
||||
Value="{Binding RetryAttempts}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||
<TextBlock Width="100" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<TextBlock Width="200" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<controls:NumberBox Minimum="1" Maximum="30"
|
||||
Value="{Binding RetryDelay}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||
<TextBlock Width="200" Text="Rate Limit Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<controls:NumberBox Minimum="1" Maximum="86400"
|
||||
Value="{Binding PlaybackRateLimitRetryDelaySeconds}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0 5">
|
||||
<TextBlock Width="200" Text="Rate Limit Max Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
|
||||
<controls:NumberBox Minimum="1" Maximum="86400"
|
||||
Value="{Binding RetryMaxDelaySeconds}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
|
|
@ -246,96 +279,212 @@
|
|||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">
|
||||
<controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="Notifications"
|
||||
IconSource="AlertOn"
|
||||
Description="Configure sound, file execution, and webhook notifications"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Sound notification" Description="Play a sound file when the queue finishes">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
<StackPanel Spacing="10" Width="520">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBlock Grid.Column="0"
|
||||
IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
|
||||
FontSize="15"
|
||||
Opacity="0.8"
|
||||
TextWrapping="NoWrap"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Select Finished Sound" FontSize="15" />
|
||||
<TextBlock Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</TextBlock>
|
||||
|
||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Command="{Binding ClearFinishedSoundPath}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Remove Finished Sound Path" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Select notification sound" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
|
||||
</StackPanel>
|
||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Command="{Binding TestFinishedSoundAsync}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Test selected notification sound" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Play" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button IsVisible="{Binding IsTestingFinishedSound}"
|
||||
Command="{Binding StopFinishedSoundAsync}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Stop notification sound test" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Stop" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
|
||||
Command="{Binding ClearFinishedSoundPath}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Clear selected notification sound" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Text="This notification is sent only when the full queue finishes." />
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
|
||||
<controls:SettingsExpanderItem Content="Execute file" Description="Run a selected file when the queue finishes">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
|
||||
<StackPanel Spacing="10" Width="520">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
||||
<TextBlock Grid.Column="0"
|
||||
IsVisible="{Binding DownloadFinishedExecute}"
|
||||
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
|
||||
FontSize="15"
|
||||
Opacity="0.8"
|
||||
TextWrapping="NoWrap"
|
||||
TextAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
||||
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
|
||||
<TextBlock Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</TextBlock>
|
||||
|
||||
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
||||
Command="{Binding ClearFinishedExectuePath}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Clear selected file" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
||||
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Select file to execute" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
|
||||
</StackPanel>
|
||||
<Button IsVisible="{Binding DownloadFinishedExecute}"
|
||||
Command="{Binding ClearFinishedExectuePath}"
|
||||
VerticalAlignment="Center"
|
||||
FontStyle="Italic">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Clear selected file" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<CheckBox IsChecked="{Binding DownloadFinishedExecute}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Text="This action runs only when the full queue finishes." />
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Webhook" Description="Send an HTTP request when selected notification events are raised">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="10" Width="520">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<TextBox Watermark="https://example.com/webhook"
|
||||
MinWidth="420"
|
||||
Text="{Binding WebhookUrl}" />
|
||||
<CheckBox IsChecked="{Binding WebhookEnabled}" />
|
||||
</StackPanel>
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Method" />
|
||||
<TextBox MinWidth="120" Text="{Binding WebhookMethod}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Content type" />
|
||||
<TextBox MinWidth="220" Text="{Binding WebhookContentType}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Headers" />
|
||||
<TextBox AcceptsReturn="True"
|
||||
Height="90"
|
||||
Text="{Binding WebhookHeadersText}"
|
||||
Watermark="Authorization: Bearer token X-App: CRD" />
|
||||
<TextBlock Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Text="Enter one header per line in the format Name: Value." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Body template" />
|
||||
<TextBox AcceptsReturn="True"
|
||||
Height="120"
|
||||
Text="{Binding WebhookBodyTemplate}"
|
||||
Watermark="JSON template using placeholders like {{eventType}} and {{message}}" />
|
||||
<TextBlock Opacity="0.7"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Text="Leave empty to send the default JSON payload. Available placeholders include {{eventType}}, {{title}}, {{message}}, {{timestampUtc}}, and metadata keys." />
|
||||
</StackPanel>
|
||||
|
||||
<WrapPanel ItemWidth="245" Orientation="Horizontal">
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyQueueFinished}" Content="Queue finished" />
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFinished}" Content="Download finished" />
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFailed}" Content="Download failed" />
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyTrackedSeriesEpisodeReleased}" Content="Tracked series episode released" />
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyLoginExpired}" Content="Login expired" />
|
||||
<CheckBox IsChecked="{Binding WebhookNotifyUpdateAvailable}" Content="Update available" />
|
||||
</WrapPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button Command="{Binding TestWebhookCommand}"
|
||||
Content="Send selected test events" />
|
||||
<ProgressBar Width="120"
|
||||
IsIndeterminate="True"
|
||||
IsVisible="{Binding IsTestingWebhook}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="Sonarr Settings"
|
||||
|
|
@ -776,7 +925,7 @@
|
|||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm if traffic is routed through a VPN">
|
||||
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm that traffic is routed through a VPN. After enabling the VPN or changing location, restart the app; otherwise, Crunchyroll may still see the old login location.">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<Grid VerticalAlignment="Center">
|
||||
<Grid.ColumnDefinitions>
|
||||
|
|
@ -824,4 +973,4 @@
|
|||
</ScrollViewer>
|
||||
|
||||
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
|
|
|
|||
33
Dockerfile.webtop
Normal file
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