mirror of
https://github.com/Crunchy-DL/Crunchy-Downloader.git
synced 2026-04-21 17:01:58 +00:00
Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9813191ad | ||
|
|
638c412e49 | ||
|
|
9be2c65eb2 | ||
|
|
2715069ceb | ||
|
|
4952d74aa6 | ||
|
|
aabc10e1d8 | ||
|
|
c4ba220d1b | ||
|
|
e58a6bb32c | ||
|
|
4c330560aa | ||
|
|
199ff9f96c | ||
|
|
985fd9c00f | ||
|
|
973c45ce5c |
126 changed files with 7524 additions and 3143 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 }}
|
||||
157
CRD/App.axaml.cs
157
CRD/App.axaml.cs
|
|
@ -1,15 +1,25 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using CRD.ViewModels;
|
||||
using MainWindow = CRD.Views.MainWindow;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
|
||||
namespace CRD;
|
||||
|
||||
public partial class App : Application{
|
||||
public class App : Application{
|
||||
|
||||
private TrayIcon? trayIcon;
|
||||
private bool exitRequested;
|
||||
|
||||
public override void Initialize(){
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
|
@ -19,13 +29,28 @@ public partial class App : Application{
|
|||
var isHeadless = Environment.GetCommandLineArgs().Contains("--headless");
|
||||
|
||||
var manager = ProgramManager.Instance;
|
||||
QueueManager.Instance.RestorePersistedQueue();
|
||||
|
||||
if (!isHeadless){
|
||||
desktop.MainWindow = new MainWindow{
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
var mainWindow = new MainWindow{
|
||||
DataContext = new MainWindowViewModel(manager),
|
||||
};
|
||||
|
||||
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
|
||||
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
|
||||
desktop.Exit += (_, _) => {
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
manager.StopBackgroundTasks();
|
||||
};
|
||||
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
|
||||
|
||||
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
|
||||
desktop.MainWindow = mainWindow;
|
||||
}
|
||||
|
||||
SetupTrayIcon(desktop, mainWindow, manager);
|
||||
SetupMinimizeToTray(desktop,mainWindow,manager);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -35,5 +60,129 @@ public partial class App : Application{
|
|||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void SetupTrayIcon(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow, ProgramManager programManager){
|
||||
trayIcon = new TrayIcon{
|
||||
ToolTipText = "CRD",
|
||||
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://CRD/Assets/app_icon.ico"))),
|
||||
IsVisible = CrunchyrollManager.Instance.CrunOptions.TrayIconEnabled,
|
||||
};
|
||||
|
||||
var menu = new NativeMenu();
|
||||
|
||||
var refreshH = new NativeMenuItem("Refresh History");
|
||||
|
||||
var refreshAll = new NativeMenuItem("Refresh All");
|
||||
refreshAll.Click += (_, _) => _ = ProgramManager.Instance.RefreshHistory(FilterType.All);
|
||||
|
||||
var refreshActive = new NativeMenuItem("Refresh Active");
|
||||
refreshActive.Click += (_, _) => _ = ProgramManager.Instance.RefreshHistory(FilterType.Active);
|
||||
|
||||
var refreshNewReleases = new NativeMenuItem("Fast New Releases");
|
||||
var crunManager = CrunchyrollManager.Instance;
|
||||
refreshNewReleases.Click += (_, _) => _ = ProgramManager.Instance.RefreshHistoryWithNewReleases(crunManager,crunManager.CrunOptions);
|
||||
|
||||
refreshH.Menu = new NativeMenu{
|
||||
Items ={
|
||||
refreshAll,
|
||||
refreshActive,
|
||||
refreshNewReleases
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
menu.Items.Add(refreshH);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var exitItem = new NativeMenuItem("Exit");
|
||||
exitItem.Click += (_, _) => {
|
||||
exitRequested = true;
|
||||
trayIcon?.Dispose();
|
||||
desktop.Shutdown();
|
||||
};
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
trayIcon.Menu = menu;
|
||||
|
||||
trayIcon.Clicked += (_, _) => ShowFromTray(desktop, mainWindow);
|
||||
|
||||
TrayIcon.SetIcons(this, new TrayIcons{ trayIcon });
|
||||
}
|
||||
|
||||
private void SetupMinimizeToTray(IClassicDesktopStyleApplicationLifetime desktop, Window window , ProgramManager programManager){
|
||||
window.Closing += (_, e) => {
|
||||
if (exitRequested)
|
||||
return;
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions is{ MinimizeToTrayOnClose: true, TrayIconEnabled: true }){
|
||||
HideToTray(window);
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
|
||||
exitRequested = true;
|
||||
trayIcon?.Dispose();
|
||||
desktop.Shutdown();
|
||||
};
|
||||
|
||||
window.GetObservable(Window.WindowStateProperty).Subscribe(state => {
|
||||
if (CrunchyrollManager.Instance.CrunOptions is{ TrayIconEnabled: true, MinimizeToTray: true } && state == WindowState.Minimized)
|
||||
HideToTray(window);
|
||||
});
|
||||
}
|
||||
|
||||
private static void HideToTray(Window window){
|
||||
window.ShowInTaskbar = false;
|
||||
window.Hide();
|
||||
}
|
||||
|
||||
private void ShowFromTray(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow){
|
||||
desktop.MainWindow ??= mainWindow;
|
||||
RestoreFromTray(mainWindow);
|
||||
}
|
||||
|
||||
private static void RestoreFromTray(Window window){
|
||||
window.ShowInTaskbar = true;
|
||||
window.Show();
|
||||
|
||||
if (window.WindowState == WindowState.Minimized)
|
||||
window.WindowState = WindowState.Normal;
|
||||
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
public void UpdateTrayTooltip(){
|
||||
var downloadsToProcess = QueueManager.Instance.Queue.Count(e => !e.DownloadProgress.IsFinished);
|
||||
|
||||
var options = CrunchyrollManager.Instance.CrunOptions;
|
||||
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
|
||||
|
||||
string nextRefreshString = "";
|
||||
|
||||
if (options.HistoryAutoRefreshIntervalMinutes != 0){
|
||||
var baseTime = lastRefresh == DateTime.MinValue
|
||||
? DateTime.Now
|
||||
: lastRefresh;
|
||||
|
||||
var nextRefresh = baseTime
|
||||
.AddMinutes(options.HistoryAutoRefreshIntervalMinutes)
|
||||
.ToString("t", CultureInfo.CurrentCulture);
|
||||
|
||||
nextRefreshString = $"\nNext Refresh: {nextRefresh}";
|
||||
}
|
||||
|
||||
trayIcon?.ToolTipText =
|
||||
$"Queue: {downloadsToProcess}" + nextRefreshString;
|
||||
}
|
||||
|
||||
public void SetTrayIconVisible(bool enabled){
|
||||
trayIcon?.IsVisible = enabled;
|
||||
|
||||
if (!enabled && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
|
||||
if (desktop.MainWindow is{ } w)
|
||||
RestoreFromTray(w);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
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.
|
|
@ -10,6 +10,8 @@ using System.Threading.Tasks;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Downloader.Crunchyroll.Utils;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Views;
|
||||
|
|
@ -81,7 +83,7 @@ public class CalendarManager{
|
|||
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
|
||||
|
||||
(bool IsOk, string ResponseContent, string error) response;
|
||||
if (!HttpClientReq.Instance.useFlareSolverr){
|
||||
if (!HttpClientReq.Instance.UseFlareSolverr){
|
||||
response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
} else{
|
||||
response = await HttpClientReq.Instance.SendFlareSolverrHttpRequest(request);
|
||||
|
|
@ -234,7 +236,16 @@ public class CalendarManager{
|
|||
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
|
||||
|
||||
if (newEpisodesBase is{ Data.Count: > 0 }){
|
||||
var newEpisodes = newEpisodesBase.Data;
|
||||
var newEpisodes = newEpisodesBase.Data ?? [];
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
|
||||
try{
|
||||
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine("Failed to update History from calendar");
|
||||
}
|
||||
}
|
||||
|
||||
//EpisodeAirDate
|
||||
foreach (var crBrowseEpisode in newEpisodes){
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Views;
|
||||
|
|
@ -15,9 +22,12 @@ using ReactiveUI;
|
|||
namespace CRD.Downloader.Crunchyroll;
|
||||
|
||||
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
|
||||
|
||||
public CrToken? Token;
|
||||
public CrProfile Profile = new();
|
||||
public Subscription? Subscription{ get; set; }
|
||||
public CrMultiProfile MultiProfile = new();
|
||||
|
||||
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
|
||||
|
||||
public CrAuthSettings AuthSettings = authSettings;
|
||||
|
||||
|
|
@ -32,7 +42,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
|
||||
HasPremium = false,
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private string GetTokenFilePath(){
|
||||
|
|
@ -49,9 +58,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
case "console/ps5":
|
||||
case "console/xbox_one":
|
||||
return CfgManager.PathCrToken.Replace(".json", "_console.json");
|
||||
case "---":
|
||||
return CfgManager.PathCrToken.Replace(".json", "_guest.json");
|
||||
default:
|
||||
return CfgManager.PathCrToken;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,12 +75,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
}
|
||||
|
||||
public void SetETPCookie(string refreshToken){
|
||||
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken),cookieStore);
|
||||
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"),cookieStore);
|
||||
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore);
|
||||
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore);
|
||||
}
|
||||
|
||||
public async Task AuthAnonymous(){
|
||||
string uuid = Guid.NewGuid().ToString();
|
||||
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
|
||||
|
||||
Subscription = new Subscription();
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "grant_type", "client_id" },
|
||||
|
|
@ -121,11 +133,15 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
Token.device_id = deviceId;
|
||||
Token.expires = DateTime.Now.AddSeconds((double)Token.expires_in);
|
||||
|
||||
if (EndpointEnum == CrunchyrollEndpoints.Guest){
|
||||
return;
|
||||
}
|
||||
|
||||
CfgManager.WriteJsonToFile(GetTokenFilePath(), Token);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Auth(AuthData data){
|
||||
private async Task AuthOld(AuthData data){
|
||||
string uuid = Guid.NewGuid().ToString();
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
|
|
@ -162,13 +178,13 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
|
||||
} else{
|
||||
if (response.ResponseContent.Contains("invalid_credentials")){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 5));
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Login failed. Please check your username and password.", ToastType.Error, 5));
|
||||
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
|
||||
response.ResponseContent.Contains("<title>Access denied</title>") ||
|
||||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
|
||||
response.ResponseContent.Trim().Equals("error code: 1020") ||
|
||||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5));
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
|
||||
} else{
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {response.ResponseContent.Substring(0, response.ResponseContent.Length < 200 ? response.ResponseContent.Length : 200)}",
|
||||
ToastType.Error, 5));
|
||||
|
|
@ -179,7 +195,64 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
if (Token?.refresh_token != null){
|
||||
SetETPCookie(Token.refresh_token);
|
||||
|
||||
await GetProfile();
|
||||
await GetMultiProfile();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ChangeProfile(string profileId){
|
||||
if (Token?.access_token == null && Token?.refresh_token == null ||
|
||||
Token.access_token != null && Token.refresh_token == null){
|
||||
await AuthAnonymous();
|
||||
}
|
||||
|
||||
if (Profile.Username == "???"){
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
|
||||
return;
|
||||
}
|
||||
|
||||
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
|
||||
|
||||
SetETPCookie(Token.refresh_token);
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "grant_type", "refresh_token_profile_id" },
|
||||
{ "profile_id", profileId },
|
||||
{ "device_id", uuid },
|
||||
{ "device_type", AuthSettings.Device_type },
|
||||
};
|
||||
|
||||
var requestContent = new FormUrlEncodedContent(formData);
|
||||
|
||||
var crunchyAuthHeaders = new Dictionary<string, string>{
|
||||
{ "Authorization", AuthSettings.Authorization },
|
||||
{ "User-Agent", AuthSettings.UserAgent }
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
foreach (var header in crunchyAuthHeaders){
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (Token?.refresh_token != null) SetETPCookie(Token.refresh_token);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
|
||||
|
||||
if (response.IsOk){
|
||||
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
|
||||
if (Token?.refresh_token != null){
|
||||
SetETPCookie(Token.refresh_token);
|
||||
}
|
||||
|
||||
await GetMultiProfile();
|
||||
|
||||
} else{
|
||||
Console.Error.WriteLine("Refresh Token Auth Failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,29 +272,35 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
if (profileTemp != null){
|
||||
Profile = profileTemp;
|
||||
|
||||
await GetSubscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GetSubscription(){
|
||||
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
|
||||
|
||||
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
|
||||
|
||||
if (responseSubs.IsOk){
|
||||
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
Profile.Subscription = subsc;
|
||||
Subscription = subsc;
|
||||
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
|
||||
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
|
||||
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
|
||||
var remaining = expiration - DateTime.Now;
|
||||
Profile.HasPremium = true;
|
||||
if (Profile.Subscription != null){
|
||||
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
|
||||
Profile.Subscription.NextRenewalDate = expiration;
|
||||
if (Subscription != null){
|
||||
Subscription.IsActive = remaining > TimeSpan.Zero;
|
||||
Subscription.NextRenewalDate = expiration;
|
||||
}
|
||||
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
|
||||
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
|
||||
var remaining = nonRecurringSub.EndDate - DateTime.Now;
|
||||
Profile.HasPremium = true;
|
||||
if (Profile.Subscription != null){
|
||||
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
|
||||
Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate;
|
||||
if (Subscription != null){
|
||||
Subscription.IsActive = remaining > TimeSpan.Zero;
|
||||
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
|
||||
}
|
||||
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
|
||||
Profile.HasPremium = true;
|
||||
|
|
@ -236,6 +315,27 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
Console.Error.WriteLine("Failed to check premium subscription status");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GetMultiProfile(){
|
||||
if (Token?.access_token == null){
|
||||
Console.Error.WriteLine("Missing Access Token");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token?.access_token);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
|
||||
|
||||
if (response.IsOk){
|
||||
MultiProfile = Helpers.Deserialize<CrMultiProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? new CrMultiProfile();
|
||||
|
||||
var selectedProfile = MultiProfile.Profiles.FirstOrDefault( e => e.IsSelected);
|
||||
if (selectedProfile != null) Profile = selectedProfile;
|
||||
|
||||
await GetSubscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,8 +384,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
|
||||
response.ResponseContent.Trim().Equals("error code: 1020") ||
|
||||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5));
|
||||
Console.Error.WriteLine($"Failed to login - Cloudflare error try to change to BetaAPI in settings");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
|
||||
Console.Error.WriteLine($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}");
|
||||
}
|
||||
|
||||
if (response.IsOk){
|
||||
|
|
@ -294,18 +394,26 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
|
|||
if (Token?.refresh_token != null){
|
||||
SetETPCookie(Token.refresh_token);
|
||||
|
||||
await GetProfile();
|
||||
await GetMultiProfile();
|
||||
}
|
||||
} else{
|
||||
Console.Error.WriteLine("Token Auth Failed");
|
||||
await AuthAnonymous();
|
||||
|
||||
MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshToken(bool needsToken){
|
||||
if (EndpointEnum == CrunchyrollEndpoints.Guest){
|
||||
if (Token != null && !(DateTime.Now > Token.expires)){
|
||||
return;
|
||||
}
|
||||
|
||||
await AuthAnonymousFoxy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Token?.access_token == null && Token?.refresh_token == null ||
|
||||
Token.access_token != null && Token.refresh_token == null){
|
||||
await AuthAnonymous();
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ using System.Net.Http;
|
|||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Downloader.Crunchyroll.Utils;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Downloader.Crunchyroll;
|
||||
|
|
@ -73,92 +75,102 @@ public class CrEpisode(){
|
|||
|
||||
public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){
|
||||
bool serieshasversions = true;
|
||||
|
||||
// Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
|
||||
CrunchyRollEpisodeData episode = new CrunchyRollEpisodeData();
|
||||
var episode = new CrunchyRollEpisodeData();
|
||||
|
||||
if (crunInstance.CrunOptions.History && updateHistory){
|
||||
await crunInstance.History.UpdateWithEpisodeList([dlEpisode]);
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
|
||||
var historySeries = crunInstance.HistoryList
|
||||
.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
|
||||
|
||||
if (historySeries != null){
|
||||
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(false);
|
||||
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
|
||||
crunInstance.History.MatchHistorySeriesWithSonarr(false);
|
||||
await crunInstance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
}
|
||||
}
|
||||
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier) ? dlEpisode.Identifier.Split('|')[1] : $"S{dlEpisode.SeasonNumber}";
|
||||
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
|
||||
episode.EpisodeAndLanguages = new EpisodeAndLanguage{
|
||||
Items = new List<CrunchyEpisode>(),
|
||||
Langs = new List<LanguageItem>()
|
||||
};
|
||||
// initial key
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier)
|
||||
? dlEpisode.Identifier.Split('|')[1]
|
||||
: $"S{dlEpisode.SeasonNumber}";
|
||||
|
||||
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
|
||||
|
||||
episode.EpisodeAndLanguages = new EpisodeAndLanguage();
|
||||
|
||||
// Build Variants
|
||||
if (dlEpisode.Versions != null){
|
||||
foreach (var version in dlEpisode.Versions){
|
||||
// Ensure there is only one of the same language
|
||||
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != version.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
|
||||
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? Languages.DEFAULT_lang);
|
||||
}
|
||||
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)
|
||||
?? Languages.DEFAULT_lang;
|
||||
|
||||
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
|
||||
}
|
||||
} else{
|
||||
// Episode didn't have versions, mark it as such to be logged.
|
||||
serieshasversions = false;
|
||||
// Ensure there is only one of the same language
|
||||
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != dlEpisode.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
|
||||
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale) ?? Languages.DEFAULT_lang);
|
||||
}
|
||||
|
||||
var lang = Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale)
|
||||
?? Languages.DEFAULT_lang;
|
||||
|
||||
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
|
||||
}
|
||||
|
||||
if (episode.EpisodeAndLanguages.Variants.Count == 0)
|
||||
return episode;
|
||||
|
||||
int specialIndex = 1;
|
||||
int epIndex = 1;
|
||||
var baseEp = episode.EpisodeAndLanguages.Variants[0].Item;
|
||||
|
||||
var isSpecial = baseEp.IsSpecialEpisode();
|
||||
|
||||
var isSpecial = !Regex.IsMatch(episode.EpisodeAndLanguages.Items[0].Episode ?? string.Empty, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special).
|
||||
string newKey;
|
||||
if (isSpecial && !string.IsNullOrEmpty(episode.EpisodeAndLanguages.Items[0].Episode)){
|
||||
newKey = episode.EpisodeAndLanguages.Items[0].Episode ?? "SP" + (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id);
|
||||
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
|
||||
newKey = baseEp.Episode;
|
||||
} else{
|
||||
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id) : episode.EpisodeAndLanguages.Items[0].Episode ?? epIndex + "")}";
|
||||
var epPart = baseEp.Episode ?? (baseEp.EpisodeNumber?.ToString() ?? "1");
|
||||
newKey = isSpecial
|
||||
? $"SP{epPart} {baseEp.Id}"
|
||||
: $"E{epPart}";
|
||||
}
|
||||
|
||||
episode.Key = newKey;
|
||||
|
||||
var seasonTitle = episode.EpisodeAndLanguages.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
|
||||
?? Regex.Replace(episode.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
var seasonTitle =
|
||||
episode.EpisodeAndLanguages.Variants
|
||||
.Select(v => v.Item.SeasonTitle)
|
||||
.FirstOrDefault(t => !DownloadQueueItemFactory.HasDubSuffix(t))
|
||||
?? DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle);
|
||||
|
||||
var title = episode.EpisodeAndLanguages.Items[0].Title;
|
||||
var seasonNumber = Helpers.ExtractNumberAfterS(episode.EpisodeAndLanguages.Items[0].Identifier) ?? episode.EpisodeAndLanguages.Items[0].SeasonNumber.ToString();
|
||||
var title = baseEp.Title;
|
||||
var seasonNumber = baseEp.GetSeasonNum();
|
||||
|
||||
var languages = episode.EpisodeAndLanguages.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{episode.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆
|
||||
var languages = episode.EpisodeAndLanguages.Variants
|
||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
|
||||
.ToArray();
|
||||
|
||||
Console.WriteLine($"[{episode.Key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
|
||||
|
||||
|
||||
if (!serieshasversions){
|
||||
if (!serieshasversions)
|
||||
Console.WriteLine("Couldn\'t find versions on episode, added languages with language array.");
|
||||
}
|
||||
|
||||
return episode;
|
||||
}
|
||||
|
||||
|
||||
public CrunchyEpMeta EpisodeMeta(CrunchyRollEpisodeData episodeP, List<string> dubLang){
|
||||
// var ret = new Dictionary<string, CrunchyEpMeta>();
|
||||
CrunchyEpMeta? retMeta = null;
|
||||
|
||||
var retMeta = new CrunchyEpMeta();
|
||||
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
|
||||
var hslang = crunInstance.CrunOptions.Hslang;
|
||||
|
||||
var selectedDubs = dubLang
|
||||
.Where(d => episodeP.EpisodeAndLanguages.Variants.Any(v => v.Lang.CrLocale == d))
|
||||
.ToList();
|
||||
|
||||
for (int index = 0; index < episodeP.EpisodeAndLanguages.Items.Count; index++){
|
||||
var item = episodeP.EpisodeAndLanguages.Items[index];
|
||||
foreach (var v in episodeP.EpisodeAndLanguages.Variants){
|
||||
var item = v.Item;
|
||||
var lang = v.Lang;
|
||||
|
||||
if (!dubLang.Contains(episodeP.EpisodeAndLanguages.Langs[index].CrLocale))
|
||||
if (!dubLang.Contains(lang.CrLocale))
|
||||
continue;
|
||||
|
||||
item.HideSeasonTitle = true;
|
||||
|
|
@ -173,67 +185,55 @@ public class CrEpisode(){
|
|||
item.SeriesTitle = "NO_TITLE";
|
||||
}
|
||||
|
||||
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
|
||||
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
|
||||
item.SeqId = epNum;
|
||||
|
||||
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
|
||||
if (retMeta == null){
|
||||
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
|
||||
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeriesTitle));
|
||||
|
||||
var epMeta = new CrunchyEpMeta();
|
||||
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
|
||||
epMeta.SeriesTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle))?.SeriesTitle ??
|
||||
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.SeasonTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle))?.SeasonTitle ??
|
||||
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.EpisodeNumber = item.Episode;
|
||||
epMeta.EpisodeTitle = item.Title;
|
||||
epMeta.SeasonId = item.SeasonId;
|
||||
epMeta.Season = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
|
||||
epMeta.SeriesId = item.SeriesId;
|
||||
epMeta.AbsolutEpisodeNumberE = epNum;
|
||||
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Error = false,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0
|
||||
};
|
||||
epMeta.AvailableSubs = item.SubtitleLocales;
|
||||
epMeta.Description = item.Description;
|
||||
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
|
||||
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
|
||||
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeasonTitle));
|
||||
|
||||
if (episodeP.EpisodeAndLanguages.Langs.Count > 0){
|
||||
epMeta.SelectedDubs = dubLang
|
||||
.Where(language => episodeP.EpisodeAndLanguages.Langs.Any(epLang => epLang.CrLocale == language))
|
||||
.ToList();
|
||||
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
|
||||
|
||||
retMeta = DownloadQueueItemFactory.CreateShell(
|
||||
service: StreamingService.Crunchyroll,
|
||||
seriesTitle: seriesTitle,
|
||||
seasonTitle: seasonTitle,
|
||||
episodeNumber: item.Episode,
|
||||
episodeTitle: item.GetEpisodeTitle(),
|
||||
description: item.Description,
|
||||
episodeId: item.Id,
|
||||
seriesId: item.SeriesId,
|
||||
seasonId: item.SeasonId,
|
||||
season: item.GetSeasonNum(),
|
||||
absolutEpisodeNumberE: epNum,
|
||||
image: img,
|
||||
imageBig: imgBig,
|
||||
hslang: hslang,
|
||||
availableSubs: item.SubtitleLocales,
|
||||
selectedDubs: selectedDubs
|
||||
);
|
||||
}
|
||||
|
||||
var epMetaData = epMeta.Data[0];
|
||||
var playback = item.Playback;
|
||||
if (!string.IsNullOrEmpty(item.StreamsLink)){
|
||||
epMetaData.Playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback)){
|
||||
playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback))
|
||||
item.Playback = item.StreamsLink;
|
||||
}
|
||||
|
||||
retMeta.Data.Add(DownloadQueueItemFactory.CreateVariant(
|
||||
mediaId: item.Id,
|
||||
lang: lang,
|
||||
playback: playback,
|
||||
versions: item.Versions,
|
||||
isSubbed: item.IsSubbed,
|
||||
isDubbed: item.IsDubbed
|
||||
));
|
||||
}
|
||||
|
||||
if (retMeta.Data is{ Count: > 0 }){
|
||||
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
|
||||
retMeta.Data.Add(epMetaData);
|
||||
} else{
|
||||
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
|
||||
epMeta.Data[0] = epMetaData;
|
||||
retMeta = epMeta;
|
||||
}
|
||||
|
||||
|
||||
// show ep
|
||||
item.SeqId = epNum;
|
||||
}
|
||||
|
||||
|
||||
return retMeta;
|
||||
return retMeta ?? new CrunchyEpMeta();
|
||||
}
|
||||
|
||||
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using System.Net.Http;
|
|||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Downloader.Crunchyroll;
|
||||
|
|
@ -77,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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using System.Net.Http;
|
|||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll.Music;
|
||||
|
||||
|
|
@ -183,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
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ using System.Net.Http;
|
|||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Downloader.Crunchyroll.Utils;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
|
@ -17,32 +19,44 @@ namespace CRD.Downloader.Crunchyroll;
|
|||
public class CrSeries{
|
||||
private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance;
|
||||
|
||||
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){
|
||||
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? all, List<string>? e){
|
||||
var ret = new Dictionary<string, CrunchyEpMeta>();
|
||||
|
||||
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
|
||||
|
||||
foreach (var kvp in eps){
|
||||
var key = kvp.Key;
|
||||
var episode = kvp.Value;
|
||||
var hslang = crunInstance.CrunOptions.Hslang;
|
||||
|
||||
for (int index = 0; index < episode.Items.Count; index++){
|
||||
var item = episode.Items[index];
|
||||
bool ShouldInclude(string epNum) =>
|
||||
all is true || (e != null && e.Contains(epNum));
|
||||
|
||||
if (item.IsPremiumOnly && !crunInstance.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));
|
||||
foreach (var (key, episode) in eps){
|
||||
var epNum = key.StartsWith('E') ? key[1..] : key;
|
||||
|
||||
foreach (var v in episode.Variants){
|
||||
var item = v.Item;
|
||||
var lang = v.Lang;
|
||||
|
||||
item.SeqId = epNum;
|
||||
|
||||
if (item.IsPremiumOnly && !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));
|
||||
continue;
|
||||
}
|
||||
|
||||
// history override
|
||||
var effectiveDubs = dubLang;
|
||||
if (crunInstance.CrunOptions.History){
|
||||
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
|
||||
if (dubLangList.Count > 0){
|
||||
dubLang = dubLangList;
|
||||
}
|
||||
if (dubLangList.Count > 0)
|
||||
effectiveDubs = dubLangList;
|
||||
}
|
||||
|
||||
if (!dubLang.Contains(episode.Langs[index].CrLocale))
|
||||
if (!effectiveDubs.Contains(lang.CrLocale))
|
||||
continue;
|
||||
|
||||
// season title fallbacks (same behavior)
|
||||
item.HideSeasonTitle = true;
|
||||
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
|
||||
item.SeasonTitle = item.SeriesTitle;
|
||||
|
|
@ -55,66 +69,66 @@ public class CrSeries{
|
|||
item.SeriesTitle = "NO_TITLE";
|
||||
}
|
||||
|
||||
var epNum = key.StartsWith('E') ? key[1..] : key;
|
||||
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
|
||||
// selection gate
|
||||
if (!ShouldInclude(epNum))
|
||||
continue;
|
||||
|
||||
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
|
||||
// Create base queue item once per "key"
|
||||
if (!ret.TryGetValue(key, out var qItem)){
|
||||
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
|
||||
episode.Variants.Select(x => (string?)x.Item.SeriesTitle));
|
||||
|
||||
var epMeta = new CrunchyEpMeta();
|
||||
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
|
||||
epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle))?.SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle))?.SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.EpisodeNumber = item.Episode;
|
||||
epMeta.EpisodeTitle = item.Title;
|
||||
epMeta.SeasonId = item.SeasonId;
|
||||
epMeta.Season = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
|
||||
epMeta.SeriesId = item.SeriesId;
|
||||
epMeta.AbsolutEpisodeNumberE = epNum;
|
||||
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0
|
||||
};
|
||||
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
|
||||
epMeta.Description = item.Description;
|
||||
epMeta.AvailableSubs = item.SubtitleLocales;
|
||||
if (episode.Langs.Count > 0){
|
||||
epMeta.SelectedDubs = dubLang
|
||||
.Where(language => episode.Langs.Any(epLang => epLang.CrLocale == language))
|
||||
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
|
||||
episode.Variants.Select(x => (string?)x.Item.SeasonTitle));
|
||||
|
||||
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
|
||||
|
||||
var selectedDubs = effectiveDubs
|
||||
.Where(d => episode.Variants.Any(x => x.Lang.CrLocale == d))
|
||||
.ToList();
|
||||
|
||||
qItem = DownloadQueueItemFactory.CreateShell(
|
||||
service: StreamingService.Crunchyroll,
|
||||
seriesTitle: seriesTitle,
|
||||
seasonTitle: seasonTitle,
|
||||
episodeNumber: item.Episode,
|
||||
episodeTitle: item.Title,
|
||||
description: item.Description,
|
||||
episodeId: item.Id,
|
||||
seriesId: item.SeriesId,
|
||||
seasonId: item.SeasonId,
|
||||
season: Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber.ToString(),
|
||||
absolutEpisodeNumberE: epNum,
|
||||
image: img,
|
||||
imageBig: imgBig,
|
||||
hslang: hslang,
|
||||
availableSubs: item.SubtitleLocales,
|
||||
selectedDubs: selectedDubs
|
||||
);
|
||||
|
||||
ret.Add(key, qItem);
|
||||
}
|
||||
|
||||
|
||||
var epMetaData = epMeta.Data[0];
|
||||
// playback preference
|
||||
var playback = item.Playback;
|
||||
if (!string.IsNullOrEmpty(item.StreamsLink)){
|
||||
epMetaData.Playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback)){
|
||||
playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback))
|
||||
item.Playback = item.StreamsLink;
|
||||
}
|
||||
}
|
||||
|
||||
if (all is true || e != null && e.Contains(epNum)){
|
||||
if (ret.TryGetValue(key, out var epMe)){
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMe.Data.Add(epMetaData);
|
||||
} else{
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMeta.Data[0] = epMetaData;
|
||||
ret.Add(key, epMeta);
|
||||
// Add variant
|
||||
ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
|
||||
mediaId: item.Id,
|
||||
lang: lang,
|
||||
playback: playback,
|
||||
versions: item.Versions,
|
||||
isSubbed: item.IsSubbed,
|
||||
isDubbed: item.IsDubbed
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// show ep
|
||||
item.SeqId = epNum;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
|
@ -124,64 +138,58 @@ public class CrSeries{
|
|||
|
||||
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
|
||||
|
||||
if (parsedSeries == null || parsedSeries.Data == null){
|
||||
if (parsedSeries?.Data == null){
|
||||
Console.Error.WriteLine("Parse Data Invalid");
|
||||
return null;
|
||||
}
|
||||
|
||||
// var result = ParseSeriesResult(parsedSeries);
|
||||
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
var episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
|
||||
if (crunInstance.CrunOptions.History){
|
||||
if (crunInstance.CrunOptions.History)
|
||||
_ = crunInstance.History.CrUpdateSeries(id, "");
|
||||
}
|
||||
|
||||
var cachedSeasonId = "";
|
||||
var seasonData = new CrunchyEpisodeList();
|
||||
|
||||
foreach (var s in parsedSeries.Data){
|
||||
if (data?.S != null && s.Id != data.S) continue;
|
||||
if (data?.S != null && s.Id != data.S)
|
||||
continue;
|
||||
|
||||
int fallbackIndex = 0;
|
||||
|
||||
if (cachedSeasonId != s.Id){
|
||||
seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : "");
|
||||
cachedSeasonId = s.Id;
|
||||
}
|
||||
|
||||
if (seasonData.Data != null){
|
||||
if (seasonData.Data == null)
|
||||
continue;
|
||||
|
||||
foreach (var episode in seasonData.Data){
|
||||
// Prepare the episode array
|
||||
EpisodeAndLanguage item;
|
||||
string episodeNum =
|
||||
(episode.Episode != string.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}"))
|
||||
?? string.Empty;
|
||||
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier)
|
||||
? s.Identifier.Split('|')[1]
|
||||
: $"S{episode.SeasonNumber}";
|
||||
|
||||
string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty;
|
||||
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}";
|
||||
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
|
||||
|
||||
if (!episodes.ContainsKey(episodeKey)){
|
||||
item = new EpisodeAndLanguage{
|
||||
Items = new List<CrunchyEpisode>(),
|
||||
Langs = new List<LanguageItem>()
|
||||
};
|
||||
if (!episodes.TryGetValue(episodeKey, out var item)){
|
||||
item = new EpisodeAndLanguage(); // must have Variants
|
||||
episodes[episodeKey] = item;
|
||||
} else{
|
||||
item = episodes[episodeKey];
|
||||
}
|
||||
|
||||
if (episode.Versions != null){
|
||||
foreach (var version in episode.Versions){
|
||||
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem());
|
||||
}
|
||||
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
|
||||
item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
|
||||
}
|
||||
} else{
|
||||
serieshasversions = false;
|
||||
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem());
|
||||
}
|
||||
}
|
||||
var lang = Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem();
|
||||
item.AddUnique(episode, lang);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -198,22 +206,25 @@ public class CrSeries{
|
|||
int specialIndex = 1;
|
||||
int epIndex = 1;
|
||||
|
||||
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating.
|
||||
var keys = new List<string>(episodes.Keys);
|
||||
|
||||
foreach (var key in keys){
|
||||
EpisodeAndLanguage item = episodes[key];
|
||||
var episode = item.Items[0].Episode;
|
||||
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special).
|
||||
// var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}";
|
||||
var item = episodes[key];
|
||||
if (item.Variants.Count == 0)
|
||||
continue;
|
||||
|
||||
var baseEp = item.Variants[0].Item;
|
||||
|
||||
var epStr = baseEp.Episode;
|
||||
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
|
||||
|
||||
string newKey;
|
||||
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
|
||||
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id);
|
||||
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
|
||||
newKey = $"SP{specialIndex}_" + baseEp.Episode;
|
||||
} else{
|
||||
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
|
||||
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
|
||||
}
|
||||
|
||||
|
||||
episodes.Remove(key);
|
||||
|
||||
int counter = 1;
|
||||
|
|
@ -225,63 +236,95 @@ public class CrSeries{
|
|||
|
||||
episodes.Add(newKey, item);
|
||||
|
||||
if (isSpecial){
|
||||
specialIndex++;
|
||||
} else{
|
||||
epIndex++;
|
||||
}
|
||||
if (isSpecial) specialIndex++;
|
||||
else epIndex++;
|
||||
}
|
||||
|
||||
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList();
|
||||
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList();
|
||||
var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
|
||||
var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
|
||||
|
||||
// Combining and sorting episodes with normal first, then specials.
|
||||
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
|
||||
|
||||
foreach (var kvp in sortedEpisodes){
|
||||
var key = kvp.Key;
|
||||
var item = kvp.Value;
|
||||
|
||||
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
|
||||
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
if (item.Variants.Count == 0)
|
||||
continue;
|
||||
|
||||
var title = item.Items[0].Title;
|
||||
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
|
||||
var baseEp = item.Variants[0].Item;
|
||||
|
||||
var languages = item.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆
|
||||
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
|
||||
item.Variants.Select(string? (v) => v.Item.SeasonTitle)
|
||||
);
|
||||
|
||||
var title = baseEp.Title;
|
||||
var seasonNumber = Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString();
|
||||
|
||||
var languages = item.Variants
|
||||
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
|
||||
.ToArray();
|
||||
|
||||
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
|
||||
}
|
||||
|
||||
if (!serieshasversions){
|
||||
if (!serieshasversions)
|
||||
Console.WriteLine("Couldn\'t find versions on some episodes, added languages with language array.");
|
||||
}
|
||||
|
||||
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
|
||||
crunchySeriesList.Data = sortedEpisodes;
|
||||
var crunchySeriesList = new CrunchySeriesList{
|
||||
Data = sortedEpisodes
|
||||
};
|
||||
|
||||
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
|
||||
var key = kvp.Key;
|
||||
var value = kvp.Value;
|
||||
var images = (value.Items.FirstOrDefault()?.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
|
||||
var seconds = (int)Math.Floor((value.Items.FirstOrDefault()?.DurationMs ?? 0) / 1000.0);
|
||||
var langList = value.Langs.Select(a => a.CrLocale).ToList();
|
||||
|
||||
if (value.Variants.Count == 0){
|
||||
return new Episode{
|
||||
E = key.StartsWith("E") ? key.Substring(1) : key,
|
||||
Lang = new List<string>(),
|
||||
Name = string.Empty,
|
||||
Season = string.Empty,
|
||||
SeriesTitle = string.Empty,
|
||||
SeasonTitle = string.Empty,
|
||||
EpisodeNum = key,
|
||||
Id = string.Empty,
|
||||
Img = string.Empty,
|
||||
Description = string.Empty,
|
||||
EpisodeType = EpisodeType.Episode,
|
||||
Time = "0:00"
|
||||
};
|
||||
}
|
||||
|
||||
var baseEp = value.Variants[0].Item;
|
||||
|
||||
var thumbRow = baseEp.Images.Thumbnail.FirstOrDefault();
|
||||
var img = thumbRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
|
||||
|
||||
var seconds = (int)Math.Floor((baseEp.DurationMs) / 1000.0);
|
||||
|
||||
var langList = value.Variants
|
||||
.Select(v => v.Lang.CrLocale)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Languages.SortListByLangList(langList);
|
||||
|
||||
return new Episode{
|
||||
E = key.StartsWith("E") ? key.Substring(1) : key,
|
||||
Lang = langList,
|
||||
Name = value.Items.FirstOrDefault()?.Title ?? string.Empty,
|
||||
Season = (Helpers.ExtractNumberAfterS(value.Items.FirstOrDefault()?.Identifier?? string.Empty) ?? value.Items.FirstOrDefault()?.SeasonNumber.ToString()) ?? string.Empty,
|
||||
SeriesTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeriesTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
SeasonTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeasonTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
EpisodeNum = key.StartsWith("SP") ? key : value.Items.FirstOrDefault()?.EpisodeNumber?.ToString() ?? value.Items.FirstOrDefault()?.Episode ?? "?",
|
||||
Id = value.Items.FirstOrDefault()?.SeasonId ?? string.Empty,
|
||||
Img = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty,
|
||||
Description = value.Items.FirstOrDefault()?.Description ?? string.Empty,
|
||||
Name = baseEp.Title ?? string.Empty,
|
||||
Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
|
||||
SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
|
||||
SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
|
||||
EpisodeNum = key.StartsWith("SP")
|
||||
? key
|
||||
: (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
|
||||
Id = baseEp.SeasonId ?? string.Empty,
|
||||
Img = img,
|
||||
Description = baseEp.Description ?? string.Empty,
|
||||
EpisodeType = EpisodeType.Episode,
|
||||
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
|
||||
Time = $"{seconds / 60}:{seconds % 60:D2}"
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
|
|
@ -333,7 +376,7 @@ public class CrSeries{
|
|||
Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
|
||||
} else{
|
||||
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ??
|
||||
new CrunchyEpisodeList(){ Data =[], Total = 0, Meta = new Meta() };
|
||||
new CrunchyEpisodeList(){ Data = [], Total = 0, Meta = new Meta() };
|
||||
}
|
||||
|
||||
if (episodeList.Total < 1){
|
||||
|
|
@ -456,7 +499,7 @@ public class CrSeries{
|
|||
public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){
|
||||
await crunInstance.CrAuthGuest.RefreshToken(true);
|
||||
CrBrowseSeriesBase complete = new CrBrowseSeriesBase();
|
||||
complete.Data =[];
|
||||
complete.Data = [];
|
||||
|
||||
var i = 0;
|
||||
|
||||
|
|
@ -520,5 +563,4 @@ public class CrSeries{
|
|||
|
||||
return series;
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
91
CRD/Downloader/Crunchyroll/Utils/DownloadQueueItemFactory.cs
Normal file
91
CRD/Downloader/Crunchyroll/Utils/DownloadQueueItemFactory.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Downloader.Crunchyroll.Utils;
|
||||
|
||||
public static class DownloadQueueItemFactory{
|
||||
private static readonly Regex DubSuffix = new(@"\(\w+ Dub\)", RegexOptions.Compiled);
|
||||
|
||||
public static bool HasDubSuffix(string? s)
|
||||
=> !string.IsNullOrWhiteSpace(s) && DubSuffix.IsMatch(s);
|
||||
|
||||
public static string StripDubSuffix(string? s)
|
||||
=> string.IsNullOrWhiteSpace(s) ? "" : DubSuffix.Replace(s, "").TrimEnd();
|
||||
|
||||
public static string CanonicalTitle(IEnumerable<string?> candidates){
|
||||
var noDub = candidates.FirstOrDefault(t => !HasDubSuffix(t));
|
||||
return !string.IsNullOrWhiteSpace(noDub)
|
||||
? noDub!
|
||||
: StripDubSuffix(candidates.FirstOrDefault());
|
||||
}
|
||||
|
||||
public static (string small, string big) GetThumbSmallBig(Images? images){
|
||||
var firstRow = images?.Thumbnail?.FirstOrDefault();
|
||||
var small = firstRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
|
||||
var big = firstRow?.LastOrDefault()?.Source ?? small;
|
||||
return (small, big);
|
||||
}
|
||||
|
||||
public static CrunchyEpMeta CreateShell(
|
||||
StreamingService service,
|
||||
string? seriesTitle,
|
||||
string? seasonTitle,
|
||||
string? episodeNumber,
|
||||
string? episodeTitle,
|
||||
string? description,
|
||||
string? episodeId,
|
||||
string? seriesId,
|
||||
string? seasonId,
|
||||
string? season,
|
||||
string? absolutEpisodeNumberE,
|
||||
string? image,
|
||||
string? imageBig,
|
||||
string hslang,
|
||||
List<string>? availableSubs = null,
|
||||
List<string>? selectedDubs = null,
|
||||
bool music = false){
|
||||
return new CrunchyEpMeta(){
|
||||
SeriesTitle = seriesTitle,
|
||||
SeasonTitle = seasonTitle,
|
||||
EpisodeNumber = episodeNumber,
|
||||
EpisodeTitle = episodeTitle,
|
||||
Description = description,
|
||||
|
||||
EpisodeId = episodeId,
|
||||
SeriesId = seriesId,
|
||||
SeasonId = seasonId,
|
||||
Season = season,
|
||||
AbsolutEpisodeNumberE = absolutEpisodeNumberE,
|
||||
|
||||
Image = image,
|
||||
ImageBig = imageBig,
|
||||
|
||||
Hslang = hslang,
|
||||
AvailableSubs = availableSubs,
|
||||
SelectedDubs = selectedDubs,
|
||||
Music = music
|
||||
};
|
||||
}
|
||||
|
||||
public static CrunchyEpMetaData CreateVariant(
|
||||
string mediaId,
|
||||
LanguageItem? lang,
|
||||
string? playback,
|
||||
List<EpisodeVersion>? versions,
|
||||
bool isSubbed,
|
||||
bool isDubbed,
|
||||
bool isAudioRoleDescription = false){
|
||||
return new CrunchyEpMetaData{
|
||||
MediaId = mediaId,
|
||||
Lang = lang,
|
||||
Playback = playback,
|
||||
Versions = versions,
|
||||
IsSubbed = isSubbed,
|
||||
IsDubbed = isDubbed,
|
||||
IsAudioRoleDescription = isAudioRoleDescription
|
||||
};
|
||||
}
|
||||
}
|
||||
122
CRD/Downloader/Crunchyroll/Utils/EpisodeMapper.cs
Normal file
122
CRD/Downloader/Crunchyroll/Utils/EpisodeMapper.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Downloader.Crunchyroll.Utils;
|
||||
|
||||
public static class EpisodeMapper{
|
||||
public static CrunchyEpisode ToCrunchyEpisode(this CrBrowseEpisode src){
|
||||
if (src == null) throw new ArgumentNullException(nameof(src));
|
||||
|
||||
var meta = src.EpisodeMetadata ?? new CrBrowseEpisodeMetaData();
|
||||
|
||||
return new CrunchyEpisode{
|
||||
|
||||
Id = src.Id ?? string.Empty,
|
||||
Slug = src.Slug ?? string.Empty,
|
||||
SlugTitle = src.SlugTitle ?? string.Empty,
|
||||
Title = src.Title ?? string.Empty,
|
||||
Description = src.Description ?? src.PromoDescription ?? string.Empty,
|
||||
MediaType = src.Type,
|
||||
ChannelId = src.ChannelId,
|
||||
StreamsLink = src.StreamsLink,
|
||||
Images = src.Images ?? new Images(),
|
||||
|
||||
|
||||
SeoTitle = src.PromoTitle ?? string.Empty,
|
||||
SeoDescription = src.PromoDescription ?? string.Empty,
|
||||
|
||||
|
||||
ProductionEpisodeId = src.ExternalId ?? string.Empty,
|
||||
ListingId = src.LinkedResourceKey ?? string.Empty,
|
||||
|
||||
|
||||
SeriesId = meta.SeriesId ?? string.Empty,
|
||||
SeasonId = meta.SeasonId ?? string.Empty,
|
||||
|
||||
SeriesTitle = meta.SeriesTitle ?? string.Empty,
|
||||
SeriesSlugTitle = meta.SeriesSlugTitle ?? string.Empty,
|
||||
|
||||
SeasonTitle = meta.SeasonTitle ?? string.Empty,
|
||||
SeasonSlugTitle = meta.SeasonSlugTitle ?? string.Empty,
|
||||
|
||||
SeasonNumber = SafeInt(meta.SeasonNumber),
|
||||
SequenceNumber = (float)meta.SequenceNumber,
|
||||
|
||||
Episode = meta.Episode,
|
||||
EpisodeNumber = meta.EpisodeCount,
|
||||
|
||||
DurationMs = meta.DurationMs,
|
||||
Identifier = meta.Identifier ?? string.Empty,
|
||||
|
||||
AvailabilityNotes = meta.AvailabilityNotes ?? string.Empty,
|
||||
EligibleRegion = meta.EligibleRegion ?? string.Empty,
|
||||
|
||||
AvailabilityStarts = meta.AvailabilityStarts,
|
||||
AvailabilityEnds = meta.AvailabilityEnds,
|
||||
PremiumAvailableDate = meta.PremiumAvailableDate,
|
||||
FreeAvailableDate = meta.FreeAvailableDate,
|
||||
AvailableDate = meta.AvailableDate,
|
||||
PremiumDate = meta.PremiumDate,
|
||||
UploadDate = meta.UploadDate,
|
||||
EpisodeAirDate = meta.EpisodeAirDate,
|
||||
|
||||
IsDubbed = meta.IsDubbed,
|
||||
IsSubbed = meta.IsSubbed,
|
||||
IsMature = meta.IsMature,
|
||||
IsClip = meta.IsClip,
|
||||
IsPremiumOnly = meta.IsPremiumOnly,
|
||||
MatureBlocked = meta.MatureBlocked,
|
||||
|
||||
AvailableOffline = meta.AvailableOffline,
|
||||
ClosedCaptionsAvailable = meta.ClosedCaptionsAvailable,
|
||||
|
||||
MaturityRatings = meta.MaturityRatings ?? new List<string>(),
|
||||
|
||||
|
||||
AudioLocale = (meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
|
||||
SubtitleLocales = (meta.SubtitleLocales ?? new List<Locale>())
|
||||
.Select(l => l.GetEnumMemberValue())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList(),
|
||||
|
||||
ExtendedMaturityRating = ToStringKeyDict(meta.ExtendedMaturityRating),
|
||||
|
||||
Versions = meta.versions?.Select(ToEpisodeVersion).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static EpisodeVersion ToEpisodeVersion(CrBrowseEpisodeVersion v){
|
||||
return new EpisodeVersion{
|
||||
AudioLocale = (v.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
|
||||
Guid = v.Guid ?? string.Empty,
|
||||
Original = v.Original,
|
||||
Variant = v.Variant ?? string.Empty,
|
||||
SeasonGuid = v.SeasonGuid ?? string.Empty,
|
||||
MediaGuid = v.MediaGuid,
|
||||
IsPremiumOnly = v.IsPremiumOnly,
|
||||
roles = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private static int SafeInt(double value){
|
||||
if (double.IsNaN(value) || double.IsInfinity(value)) return 0;
|
||||
return (int)Math.Round(value, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ToStringKeyDict(Dictionary<object, object>? dict){
|
||||
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
|
||||
if (dict == null) return result;
|
||||
|
||||
foreach (var kv in dict){
|
||||
var key = kv.Key?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(key)) continue;
|
||||
result[key] = kv.Value ?? new object();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -80,7 +81,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
private bool _muxToMp3;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _muxFonts;
|
||||
private bool muxFonts;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool muxTypesettingFonts;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _muxCover;
|
||||
|
|
@ -88,6 +92,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _syncTimings;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _syncTimingsFullQualityFallback;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _defaultSubSigns;
|
||||
|
||||
|
|
@ -155,10 +162,28 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
private ComboBoxItem _selectedStreamEndpoint;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _firstEndpointVideo;
|
||||
private bool firstEndpointVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _firstEndpointAudio;
|
||||
private bool firstEndpointAudio;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool firstEndpointUseDefault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string firstEndpointAuthorization = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string firstEndpointUserAgent = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string firstEndpointDeviceName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string firstEndpointDeviceType = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool firstEndpointNotSignedWarning;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem _SelectedStreamEndpointSecondary;
|
||||
|
|
@ -176,16 +201,19 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
private string _endpointDeviceType = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _endpointVideo;
|
||||
private bool endpointVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _endpointAudio;
|
||||
private bool endpointAudio;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool endpointUseDefault;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isLoggingIn;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _endpointNotSignedWarning;
|
||||
private bool endpointNotSignedWarning;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem _selectedDefaultDubLang;
|
||||
|
|
@ -246,9 +274,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
public ObservableCollection<ListBoxItem> DubLangList{ get; } = [];
|
||||
|
||||
|
||||
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [];
|
||||
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [
|
||||
new(){ Content = "none" },
|
||||
];
|
||||
|
||||
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [];
|
||||
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [
|
||||
new(){ Content = "none" },
|
||||
];
|
||||
|
||||
|
||||
public ObservableCollection<ListBoxItem> SubLangList{ get; } =[
|
||||
|
|
@ -324,6 +356,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(){
|
||||
|
|
@ -369,14 +404,25 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
EndpointDeviceType = options.StreamEndpointSecondSettings?.Device_type ?? string.Empty;
|
||||
EndpointVideo = options.StreamEndpointSecondSettings?.Video ?? true;
|
||||
EndpointAudio = options.StreamEndpointSecondSettings?.Audio ?? true;
|
||||
EndpointUseDefault = options.StreamEndpointSecondSettings?.UseDefault ?? true;
|
||||
|
||||
FirstEndpointVideo = options.StreamEndpoint?.Video ?? true;
|
||||
FirstEndpointAudio = options.StreamEndpoint?.Audio ?? true;
|
||||
FirstEndpointUseDefault = options.StreamEndpoint?.UseDefault ?? true;
|
||||
FirstEndpointAuthorization = options.StreamEndpoint?.Authorization ?? string.Empty;
|
||||
FirstEndpointUserAgent = options.StreamEndpoint?.UserAgent ?? string.Empty;
|
||||
FirstEndpointDeviceName = options.StreamEndpoint?.Device_name ?? string.Empty;
|
||||
FirstEndpointDeviceType = options.StreamEndpoint?.Device_type ?? string.Empty;
|
||||
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???"){
|
||||
EndpointNotSignedWarning = true;
|
||||
}
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???"){
|
||||
FirstEndpointNotSignedWarning = true;
|
||||
}
|
||||
|
||||
FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions());
|
||||
|
||||
if (FFmpegHWAccel.Count == 0){
|
||||
|
|
@ -441,8 +487,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
MuxToMp4 = options.Mp4;
|
||||
MuxToMp3 = options.AudioOnlyToMp3;
|
||||
MuxFonts = options.MuxFonts;
|
||||
MuxTypesettingFonts = options.MuxTypesettingFonts;
|
||||
MuxCover = options.MuxCover;
|
||||
SyncTimings = options.SyncTiming;
|
||||
SyncTimingsFullQualityFallback = options.SyncTimingFullQualityFallback;
|
||||
SkipSubMux = options.SkipSubsMux;
|
||||
LeadingNumbers = options.Numbers;
|
||||
FileName = options.FileName;
|
||||
|
|
@ -516,8 +564,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.Mp4 = MuxToMp4;
|
||||
CrunchyrollManager.Instance.CrunOptions.AudioOnlyToMp3 = MuxToMp3;
|
||||
CrunchyrollManager.Instance.CrunOptions.MuxFonts = MuxFonts;
|
||||
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;
|
||||
|
|
@ -552,6 +602,11 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
endpointSettingsFirst.Endpoint = SelectedStreamEndpoint.Content + "";
|
||||
endpointSettingsFirst.Video = FirstEndpointVideo;
|
||||
endpointSettingsFirst.Audio = FirstEndpointAudio;
|
||||
endpointSettingsFirst.UseDefault = FirstEndpointUseDefault;
|
||||
endpointSettingsFirst.Authorization = FirstEndpointAuthorization;
|
||||
endpointSettingsFirst.UserAgent = FirstEndpointUserAgent;
|
||||
endpointSettingsFirst.Device_name = FirstEndpointDeviceName;
|
||||
endpointSettingsFirst.Device_type = FirstEndpointDeviceType;
|
||||
CrunchyrollManager.Instance.CrunOptions.StreamEndpoint = endpointSettingsFirst;
|
||||
|
||||
var endpointSettings = new CrAuthSettings();
|
||||
|
|
@ -562,6 +617,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
endpointSettings.Device_type = EndpointDeviceType;
|
||||
endpointSettings.Video = EndpointVideo;
|
||||
endpointSettings.Audio = EndpointAudio;
|
||||
endpointSettings.UseDefault = EndpointUseDefault;
|
||||
|
||||
|
||||
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings;
|
||||
|
|
@ -728,13 +784,53 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
|
||||
[RelayCommand]
|
||||
public void ResetEndpointSettings(){
|
||||
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == ("android/phone")) ?? null;
|
||||
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];
|
||||
|
||||
EndpointAuthorization = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Authorization;
|
||||
EndpointUserAgent = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.UserAgent;
|
||||
EndpointDeviceName = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_name;
|
||||
EndpointDeviceType = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_type;
|
||||
EndpointAuthorization = defaultSettings.Authorization;
|
||||
EndpointUserAgent = defaultSettings.UserAgent;
|
||||
EndpointDeviceName = defaultSettings.Device_name;
|
||||
EndpointDeviceType = defaultSettings.Device_type;
|
||||
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
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];
|
||||
|
||||
FirstEndpointAuthorization = defaultSettings.Authorization;
|
||||
FirstEndpointUserAgent = defaultSettings.UserAgent;
|
||||
FirstEndpointDeviceName = defaultSettings.Device_name;
|
||||
FirstEndpointDeviceType = defaultSettings.Device_type;
|
||||
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -755,6 +851,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
|
|||
await viewModel.LoginCompleted;
|
||||
IsLoggingIn = false;
|
||||
EndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???";
|
||||
FirstEndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???";
|
||||
}
|
||||
|
||||
private List<StringItemWithDisplayName> GetAvailableHWAccelOptions(){
|
||||
|
|
|
|||
|
|
@ -264,16 +264,68 @@
|
|||
</ComboBox>
|
||||
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding FirstEndpointVideo}" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding FirstEndpointAudio}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Use Default" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding FirstEndpointUseDefault}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !FirstEndpointUseDefault}">
|
||||
<TextBlock Text="Authorization" />
|
||||
<TextBox Name="FirstAuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding FirstEndpointAuthorization}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !FirstEndpointUseDefault}">
|
||||
<TextBlock Text="User Agent" />
|
||||
<TextBox Name="FirstUserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding FirstEndpointUserAgent}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !FirstEndpointUseDefault}">
|
||||
<TextBlock Text="Device Type" />
|
||||
<TextBox Name="FirstDeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding FirstEndpointDeviceType}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !FirstEndpointUseDefault}">
|
||||
<TextBlock Text="Device Name" />
|
||||
<TextBox Name="FirstDeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding FirstEndpointDeviceName}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Margin="5 10" VerticalAlignment="Center"
|
||||
IsEnabled="{Binding !FirstEndpointUseDefault}"
|
||||
Command="{Binding ResetFirstEndpointSettings}">
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
|
||||
<TextBlock Text="Reset" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<controls:SymbolIcon Symbol="CloudOff"
|
||||
IsVisible="{Binding FirstEndpointNotSignedWarning}"
|
||||
Foreground="OrangeRed"
|
||||
FontSize="30"
|
||||
VerticalAlignment="Center">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Signin for this endpoint failed. Check logs for more details."
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="250" />
|
||||
</ToolTip.Tip>
|
||||
</controls:SymbolIcon>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
|
|
@ -288,35 +340,41 @@
|
|||
</ComboBox>
|
||||
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EndpointVideo}" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EndpointAudio}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5">
|
||||
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Use Default" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
|
||||
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EndpointUseDefault}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
|
||||
<TextBlock Text="Authorization" />
|
||||
<TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding EndpointAuthorization}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5">
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
|
||||
<TextBlock Text="User Agent" />
|
||||
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding EndpointUserAgent}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5">
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
|
||||
<TextBlock Text="Device Type" />
|
||||
<TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding EndpointDeviceType}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Margin="0,5">
|
||||
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
|
||||
<TextBlock Text="Device Name" />
|
||||
<TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
|
||||
Text="{Binding EndpointDeviceName}" />
|
||||
|
|
@ -324,6 +382,7 @@
|
|||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Margin="5 10" VerticalAlignment="Center"
|
||||
IsEnabled="{Binding !EndpointUseDefault}"
|
||||
Command="{Binding ResetEndpointSettings}">
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
|
||||
<TextBlock Text="Reset" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" />
|
||||
|
|
@ -448,7 +507,7 @@
|
|||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Filename"
|
||||
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\">
|
||||
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\ Sonarr: ${sonarrSeriesTitle} ${sonarrSeriesReleaseYear} ${sonarrEpisodeTitle}">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox Name="FileNameTextBox" HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding FileName}" />
|
||||
|
|
@ -521,7 +580,18 @@
|
|||
|
||||
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Include Fonts" Description="Includes the fonts in the mkv">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding MuxFonts}"> </CheckBox>
|
||||
|
||||
<StackPanel>
|
||||
<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>
|
||||
|
||||
|
|
@ -570,6 +640,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>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ using System.Globalization;
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Downloader.Crunchyroll.Utils;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Sonarr;
|
||||
|
|
@ -58,31 +59,59 @@ public class History{
|
|||
if (parsedSeries.Data != null){
|
||||
var result = false;
|
||||
foreach (var s in parsedSeries.Data){
|
||||
var sId = s.Id;
|
||||
var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
|
||||
? crunInstance.DefaultLocale
|
||||
: crunInstance.CrunOptions.HistoryLang;
|
||||
|
||||
var candidateIds = new List<string>();
|
||||
|
||||
if (s.Versions is{ Count: > 0 }){
|
||||
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){
|
||||
if (sVersion.Guid != null){
|
||||
sId = sVersion.Guid;
|
||||
candidateIds.AddRange(
|
||||
s.Versions
|
||||
.Where(v => v.Original == true && !string.IsNullOrWhiteSpace(v.Guid))
|
||||
.OrderByDescending(v => v.Guid!.Length)
|
||||
.Select(v => v.Guid!)
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(s.Id)){
|
||||
candidateIds.Add(s.Id);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(seasonId) && sId != seasonId) continue;
|
||||
candidateIds = candidateIds
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (!string.IsNullOrEmpty(seasonId) &&
|
||||
!candidateIds.Contains(seasonId, StringComparer.OrdinalIgnoreCase)){
|
||||
continue;
|
||||
}
|
||||
|
||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(sId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
|
||||
foreach (var candidateId in candidateIds){
|
||||
try{
|
||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(candidateId, lang, true);
|
||||
|
||||
if (seasonData.Data is{ Count: > 0 }){
|
||||
result = true;
|
||||
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
||||
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
|
||||
break;
|
||||
}
|
||||
} catch{
|
||||
// optional: log candidateId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
historySeries ??= crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
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();
|
||||
|
|
@ -129,6 +158,129 @@ public class History{
|
|||
}
|
||||
}
|
||||
|
||||
public async Task UpdateWithEpisode(List<CrBrowseEpisode> episodes){
|
||||
var historyIndex = crunInstance.HistoryList
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.SeriesId))
|
||||
.ToDictionary(
|
||||
h => h.SeriesId!,
|
||||
h => (h.Seasons)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.SeasonId))
|
||||
.ToDictionary(
|
||||
s => s.SeasonId ?? "UNKNOWN",
|
||||
s => (s.EpisodesList)
|
||||
.Select(ep => ep.EpisodeId)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.ToHashSet(StringComparer.Ordinal),
|
||||
StringComparer.Ordinal
|
||||
),
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
|
||||
episodes = episodes
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.EpisodeMetadata?.SeriesId) &&
|
||||
historyIndex.ContainsKey(e.EpisodeMetadata!.SeriesId!))
|
||||
.ToList();
|
||||
|
||||
foreach (var seriesGroup in episodes.GroupBy(e => e.EpisodeMetadata?.SeriesId ?? "UNKNOWN_SERIES")){
|
||||
var seriesId = seriesGroup.Key;
|
||||
|
||||
var originalEntries = seriesGroup
|
||||
.Select(e => new{ OriginalId = TryGetOriginalId(e), SeasonId = TryGetOriginalSeasonId(e) })
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.OriginalId))
|
||||
.GroupBy(x => x.OriginalId!, StringComparer.Ordinal)
|
||||
.Select(g => new{
|
||||
OriginalId = g.Key,
|
||||
SeasonId = g.Select(x => x.SeasonId).FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var hasAnyOriginalInfo = originalEntries.Count > 0;
|
||||
|
||||
var allOriginalsInHistory =
|
||||
hasAnyOriginalInfo
|
||||
&& originalEntries.All(x => IsOriginalInHistory(historyIndex, seriesId, x.SeasonId, x.OriginalId));
|
||||
|
||||
var originalItems = seriesGroup.Where(IsOriginalItem).ToList();
|
||||
|
||||
if (originalItems.Count > 0){
|
||||
if (allOriginalsInHistory){
|
||||
var sT = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
|
||||
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
|
||||
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} {sT} - all ORIGINAL episodes already in history.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var convertedList = originalItems.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
|
||||
|
||||
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var seriesTitle = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
|
||||
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
|
||||
|
||||
if (allOriginalsInHistory){
|
||||
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} - originals implied by Versions already in history.");
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WARN] No original ITEM found for SeriesId={seriesId} {seriesTitle}");
|
||||
|
||||
if (HasAllSeriesEpisodesInHistory(historyIndex, seriesId, seriesGroup)){
|
||||
Console.WriteLine($"[History] Skip (already in history): {seriesId}");
|
||||
} else{
|
||||
await CrUpdateSeries(seriesId, null);
|
||||
Console.WriteLine($"[History] Updating (full series): {seriesId}");
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private string? TryGetOriginalId(CrBrowseEpisode e) =>
|
||||
e.EpisodeMetadata?.versions?
|
||||
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.Guid))
|
||||
?.Guid;
|
||||
|
||||
private string? TryGetOriginalSeasonId(CrBrowseEpisode e) =>
|
||||
e.EpisodeMetadata?.versions?
|
||||
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.SeasonGuid))
|
||||
?.SeasonGuid
|
||||
?? e.EpisodeMetadata?.SeasonId;
|
||||
|
||||
private bool IsOriginalItem(CrBrowseEpisode e){
|
||||
var originalId = TryGetOriginalId(e);
|
||||
return !string.IsNullOrWhiteSpace(originalId)
|
||||
&& !string.IsNullOrWhiteSpace(e.Id)
|
||||
&& string.Equals(e.Id, originalId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
private bool IsOriginalInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, string? seasonId, string originalEpisodeId){
|
||||
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(seasonId))
|
||||
return seasons.TryGetValue(seasonId, out var eps) && eps.Contains(originalEpisodeId);
|
||||
|
||||
return seasons.Values.Any(eps => eps.Contains(originalEpisodeId));
|
||||
}
|
||||
|
||||
private bool HasAllSeriesEpisodesInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, IEnumerable<CrBrowseEpisode> seriesEpisodes){
|
||||
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
|
||||
|
||||
var allHistoryEpisodeIds = seasons.Values
|
||||
.SelectMany(set => set)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var e in seriesEpisodes){
|
||||
if (string.IsNullOrWhiteSpace(e.Id)) return false;
|
||||
if (!allHistoryEpisodeIds.Contains(e.Id)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method updates the History with a list of episodes. The episodes have to be from the same season.
|
||||
/// </summary>
|
||||
|
|
@ -139,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){
|
||||
|
|
@ -183,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();
|
||||
|
||||
|
|
@ -201,14 +351,17 @@ 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{
|
||||
SeriesTitle = firstEpisode.GetSeriesTitle(),
|
||||
SeriesId = firstEpisode.GetSeriesId(),
|
||||
Seasons =[],
|
||||
Seasons = [],
|
||||
HistorySeriesAddDate = DateTime.Now,
|
||||
SeriesType = firstEpisode.GetSeriesType(),
|
||||
SeriesType = SeriesType.Unknown,
|
||||
SeriesStreamingService = StreamingService.Crunchyroll
|
||||
};
|
||||
crunInstance.HistoryList.Add(historySeries);
|
||||
|
|
@ -217,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();
|
||||
|
|
@ -253,21 +407,10 @@ public class History{
|
|||
}
|
||||
|
||||
public HistoryEpisode? GetHistoryEpisode(string? seriesId, string? seasonId, string episodeId){
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
|
||||
|
||||
if (historySeason != null){
|
||||
var historyEpisode = historySeason.EpisodesList.Find(e => e.EpisodeId == episodeId);
|
||||
|
||||
if (historyEpisode != null){
|
||||
return historyEpisode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return CrunchyrollManager.Instance.HistoryList
|
||||
.FirstOrDefault(series => series.SeriesId == seriesId)?
|
||||
.Seasons.FirstOrDefault(season => season.SeasonId == seasonId)?
|
||||
.EpisodesList.Find(e => e.EpisodeId == episodeId);
|
||||
}
|
||||
|
||||
public (HistoryEpisode? historyEpisode, string downloadDirPath) GetHistoryEpisodeWithDownloadDir(string? seriesId, string? seasonId, string episodeId){
|
||||
|
|
@ -302,8 +445,8 @@ public class History{
|
|||
|
||||
var downloadDirPath = "";
|
||||
var videoQuality = "";
|
||||
List<string> dublist =[];
|
||||
List<string> sublist =[];
|
||||
List<string> dublist = [];
|
||||
List<string> sublist = [];
|
||||
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
|
||||
|
|
@ -353,7 +496,7 @@ public class History{
|
|||
public List<string> GetDubList(string? seriesId, string? seasonId){
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
List<string> dublist =[];
|
||||
List<string> dublist = [];
|
||||
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
|
||||
|
|
@ -372,7 +515,7 @@ public class History{
|
|||
public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
List<string> sublist =[];
|
||||
List<string> sublist = [];
|
||||
var videoQuality = "";
|
||||
|
||||
if (historySeries != null){
|
||||
|
|
@ -402,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();
|
||||
|
|
@ -430,8 +573,8 @@ public class History{
|
|||
SeriesId = artisteData.Id,
|
||||
SeriesTitle = artisteData.Name ?? "",
|
||||
ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "",
|
||||
HistorySeriesAvailableDubLang =[],
|
||||
HistorySeriesAvailableSoftSubs =[]
|
||||
HistorySeriesAvailableDubLang = [],
|
||||
HistorySeriesAvailableSoftSubs = []
|
||||
};
|
||||
|
||||
historySeries.SeriesDescription = cachedSeries.SeriesDescription;
|
||||
|
|
@ -545,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);
|
||||
|
|
@ -557,13 +725,46 @@ 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{
|
||||
SeasonTitle = firstEpisode.GetSeasonTitle(),
|
||||
SeasonId = firstEpisode.GetSeasonId(),
|
||||
SeasonNum = firstEpisode.GetSeasonNum(),
|
||||
EpisodesList =[],
|
||||
EpisodesList = [],
|
||||
SpecialSeason = firstEpisode.IsSpecialSeason()
|
||||
};
|
||||
|
||||
|
|
@ -583,6 +784,7 @@ public class History{
|
|||
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
|
||||
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
|
||||
EpisodeType = historySource.GetEpisodeType(),
|
||||
EpisodeSeriesType = historySource.GetSeriesType(),
|
||||
IsEpisodeAvailableOnStreamingService = true,
|
||||
ThumbnailImageUrl = historySource.GetImageUrl(),
|
||||
};
|
||||
|
|
@ -631,7 +833,7 @@ public class History{
|
|||
|
||||
historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
|
||||
|
||||
List<HistoryEpisode> allHistoryEpisodes =[];
|
||||
List<HistoryEpisode> allHistoryEpisodes = [];
|
||||
|
||||
foreach (var historySeriesSeason in historySeries.Seasons){
|
||||
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
|
||||
|
|
@ -659,7 +861,7 @@ public class History{
|
|||
.ToList();
|
||||
}
|
||||
|
||||
List<HistoryEpisode> failedEpisodes =[];
|
||||
List<HistoryEpisode> failedEpisodes = [];
|
||||
|
||||
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
|
||||
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
|
||||
|
|
|
|||
|
|
@ -3,61 +3,45 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Utils.Updater;
|
||||
using FluentAvalonia.Styling;
|
||||
using ProtoBuf.Meta;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public partial class ProgramManager : ObservableObject{
|
||||
#region Singelton
|
||||
public sealed partial class ProgramManager : ObservableObject{
|
||||
|
||||
private static ProgramManager? _instance;
|
||||
private static readonly object Padlock = new();
|
||||
|
||||
public static ProgramManager Instance{
|
||||
get{
|
||||
if (_instance == null){
|
||||
lock (Padlock){
|
||||
if (_instance == null){
|
||||
_instance = new ProgramManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
public static ProgramManager Instance{ get; } = new();
|
||||
|
||||
|
||||
#region Observables
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _fetchingData;
|
||||
private bool fetchingData;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _updateAvailable = true;
|
||||
private bool updateAvailable = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _opacityButton = 0.4;
|
||||
private bool finishedLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _finishedLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _navigationLock;
|
||||
private bool navigationLock;
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
@ -70,15 +54,17 @@ public 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;
|
||||
|
||||
public IStorageProvider StorageProvider;
|
||||
public IStorageProvider? StorageProvider;
|
||||
|
||||
public ProgramManager(){
|
||||
checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); });
|
||||
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme;
|
||||
|
||||
foreach (var arg in Environment.GetCommandLineArgs()){
|
||||
|
|
@ -106,12 +92,12 @@ public partial class ProgramManager : ObservableObject{
|
|||
}
|
||||
}
|
||||
|
||||
Init();
|
||||
_ = Init();
|
||||
|
||||
CleanUpOldUpdater();
|
||||
}
|
||||
|
||||
private async Task RefreshHistory(FilterType filterType){
|
||||
internal async Task RefreshHistory(FilterType filterType){
|
||||
FetchingData = true;
|
||||
|
||||
|
||||
|
|
@ -172,12 +158,60 @@ public partial class ProgramManager : ObservableObject{
|
|||
await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){
|
||||
while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
|
||||
Console.WriteLine("Waiting for downloads to complete...");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckForDownloadsAsync(CancellationToken ct){
|
||||
var crunchyManager = CrunchyrollManager.Instance;
|
||||
var crunOptions = crunchyManager.CrunOptions;
|
||||
|
||||
if (!crunOptions.History){
|
||||
return;
|
||||
}
|
||||
|
||||
switch (crunOptions.HistoryAutoRefreshMode){
|
||||
case HistoryRefreshMode.DefaultAll:
|
||||
await RefreshHistory(FilterType.All);
|
||||
break;
|
||||
case HistoryRefreshMode.DefaultActive:
|
||||
await RefreshHistory(FilterType.Active);
|
||||
break;
|
||||
case HistoryRefreshMode.FastNewReleases:
|
||||
await RefreshHistoryWithNewReleases(crunchyManager, crunOptions);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var tasks = crunchyManager.HistoryList
|
||||
.Select(item => item.AddNewMissingToDownloads(true));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
if (Application.Current is App app){
|
||||
Dispatcher.UIThread.Post(app.UpdateTrayTooltip);
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
|
||||
try{
|
||||
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
|
||||
CfgManager.UpdateHistoryFile();
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine("Failed to update History: " + e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetBackgroundImage(){
|
||||
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){
|
||||
Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity,
|
||||
|
|
@ -186,11 +220,11 @@ public partial class ProgramManager : ObservableObject{
|
|||
}
|
||||
|
||||
private async Task Init(){
|
||||
try{
|
||||
CrunchyrollManager.Instance.InitOptions();
|
||||
|
||||
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
|
||||
|
||||
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
|
||||
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);
|
||||
|
|
@ -213,6 +247,13 @@ public partial class ProgramManager : ObservableObject{
|
|||
FinishedLoading = true;
|
||||
|
||||
await WorkOffArgsTasks();
|
||||
|
||||
StartRunners(true);
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
} finally{
|
||||
NavigationLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -230,7 +271,7 @@ public partial class ProgramManager : ObservableObject{
|
|||
|
||||
if (exitOnTaskFinish){
|
||||
Console.WriteLine("Exiting...");
|
||||
IClassicDesktopStyleApplicationLifetime? lifetime = (IClassicDesktopStyleApplicationLifetime)Application.Current?.ApplicationLifetime;
|
||||
IClassicDesktopStyleApplicationLifetime? lifetime = (IClassicDesktopStyleApplicationLifetime?)Application.Current?.ApplicationLifetime;
|
||||
if (lifetime != null){
|
||||
lifetime.Shutdown();
|
||||
} else{
|
||||
|
|
@ -239,7 +280,6 @@ public partial class ProgramManager : ObservableObject{
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private void CleanUpOldUpdater(){
|
||||
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
|
||||
|
||||
|
|
@ -256,4 +296,17 @@ public partial class ProgramManager : ObservableObject{
|
|||
Console.WriteLine("No old updater file found to delete.");
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime GetLastRefreshTime(){
|
||||
return checkForNewEpisodesRunner.LastRunTime;
|
||||
}
|
||||
|
||||
public void StartRunners(bool runImmediately = false){
|
||||
checkForNewEpisodesRunner.StartOrRestartMinutes(CrunchyrollManager.Instance.CrunOptions.HistoryAutoRefreshIntervalMinutes,runImmediately);
|
||||
}
|
||||
|
||||
public void StopBackgroundTasks(){
|
||||
checkForNewEpisodesRunner.Stop();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -9,499 +9,331 @@ 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 partial class QueueManager : ObservableObject{
|
||||
public sealed partial class QueueManager : ObservableObject{
|
||||
public static QueueManager Instance{ get; } = new();
|
||||
|
||||
#region Download Variables
|
||||
|
||||
public RefreshableObservableCollection<CrunchyEpMeta> Queue = new RefreshableObservableCollection<CrunchyEpMeta>();
|
||||
public ObservableCollection<DownloadItemModel> DownloadItemModels = new ObservableCollection<DownloadItemModel>();
|
||||
private int activeDownloads;
|
||||
private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = new();
|
||||
public ReadOnlyObservableCollection<CrunchyEpMeta> Queue{ get; }
|
||||
|
||||
public int ActiveDownloads => Volatile.Read(ref activeDownloads);
|
||||
private readonly DownloadItemModelCollection downloadItems = new();
|
||||
|
||||
public readonly SemaphoreSlim activeProcessingJobs = new SemaphoreSlim(initialCount: CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs, maxCount: int.MaxValue);
|
||||
private int _limit = CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs;
|
||||
private int _borrowed = 0;
|
||||
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;
|
||||
|
||||
#endregion
|
||||
|
||||
public int ActiveDownloads{
|
||||
get{
|
||||
lock (downloadStartLock){
|
||||
return activeOrStarting.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasActiveDownloads => ActiveDownloads > 0;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasFailedItem;
|
||||
private bool hasFailedItem;
|
||||
|
||||
#region Singelton
|
||||
public event EventHandler? QueueStateChanged;
|
||||
|
||||
private static QueueManager? _instance;
|
||||
private static readonly object Padlock = new();
|
||||
|
||||
public static QueueManager Instance{
|
||||
get{
|
||||
if (_instance == null){
|
||||
lock (Padlock){
|
||||
if (_instance == null){
|
||||
_instance = new QueueManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
private readonly CrunchyrollManager crunchyrollManager;
|
||||
|
||||
public QueueManager(){
|
||||
Queue.CollectionChanged += UpdateItemListOnRemove;
|
||||
crunchyrollManager = CrunchyrollManager.Instance;
|
||||
|
||||
uiMutationQueue = new UiMutationQueue();
|
||||
queuePersistenceManager = new QueuePersistenceManager(this);
|
||||
Queue = new ReadOnlyObservableCollection<CrunchyEpMeta>(queue);
|
||||
|
||||
processingSlots = new ProcessingSlotManager(
|
||||
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
|
||||
|
||||
queue.CollectionChanged += UpdateItemListOnRemove;
|
||||
queue.CollectionChanged += (_, _) => OnQueueStateChanged();
|
||||
}
|
||||
|
||||
public void IncrementDownloads(){
|
||||
Interlocked.Increment(ref activeDownloads);
|
||||
public void AddToQueue(CrunchyEpMeta item){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
if (!queue.Contains(item))
|
||||
queue.Add(item);
|
||||
});
|
||||
}
|
||||
|
||||
public void ResetDownloads(){
|
||||
Interlocked.Exchange(ref activeDownloads, 0);
|
||||
public void RemoveFromQueue(CrunchyEpMeta item){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
int index = queue.IndexOf(item);
|
||||
if (index >= 0)
|
||||
queue.RemoveAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
public void DecrementDownloads(){
|
||||
while (true){
|
||||
int current = Volatile.Read(ref activeDownloads);
|
||||
if (current == 0) return;
|
||||
|
||||
if (Interlocked.CompareExchange(ref activeDownloads, current - 1, current) == current)
|
||||
return;
|
||||
public void ClearQueue(){
|
||||
uiMutationQueue.Enqueue(() => queue.Clear());
|
||||
}
|
||||
|
||||
public void RefreshQueue(){
|
||||
uiMutationQueue.Enqueue(() => queue.Refresh());
|
||||
}
|
||||
|
||||
|
||||
public bool TryStartDownload(DownloadItemModel model){
|
||||
var item = model.epMeta;
|
||||
|
||||
lock (downloadStartLock){
|
||||
if (activeOrStarting.Contains(item))
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||
return false;
|
||||
|
||||
if (item.DownloadProgress.IsDone)
|
||||
return false;
|
||||
|
||||
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)
|
||||
return false;
|
||||
|
||||
activeOrStarting.Add(item);
|
||||
}
|
||||
|
||||
NotifyDownloadStateChanged();
|
||||
OnQueueStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ReleaseDownloadSlot(CrunchyEpMeta item){
|
||||
bool removed;
|
||||
|
||||
lock (downloadStartLock){
|
||||
removed = activeOrStarting.Remove(item);
|
||||
}
|
||||
|
||||
if (removed){
|
||||
NotifyDownloadStateChanged();
|
||||
OnQueueStateChanged();
|
||||
|
||||
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||
RequestPump();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
|
||||
return processingSlots.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void ReleaseProcessingSlot(){
|
||||
processingSlots.Release();
|
||||
}
|
||||
|
||||
public void SetProcessingLimit(int newLimit){
|
||||
processingSlots.SetLimit(newLimit);
|
||||
}
|
||||
|
||||
public void RestorePersistedQueue(){
|
||||
queuePersistenceManager.RestoreQueue();
|
||||
}
|
||||
|
||||
public void SaveQueueSnapshot(){
|
||||
queuePersistenceManager.SaveNow();
|
||||
}
|
||||
|
||||
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> items){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
queue.Clear();
|
||||
foreach (var item in items){
|
||||
if (!queue.Contains(item))
|
||||
queue.Add(item);
|
||||
}
|
||||
|
||||
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 UpdateDownloadListItems(){
|
||||
var list = Queue;
|
||||
|
||||
foreach (CrunchyEpMeta crunchyEpMeta in list){
|
||||
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
|
||||
if (downloadItem != null){
|
||||
downloadItem.Refresh();
|
||||
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
|
||||
uiMutationQueue.Enqueue(() => {
|
||||
if (removeFromQueue){
|
||||
int index = queue.IndexOf(item);
|
||||
if (index >= 0)
|
||||
queue.RemoveAt(index);
|
||||
} else{
|
||||
downloadItem = new DownloadItemModel(crunchyEpMeta);
|
||||
_ = downloadItem.LoadImage();
|
||||
DownloadItemModels.Add(downloadItem);
|
||||
queue.Refresh();
|
||||
}
|
||||
|
||||
if (downloadItem is{ isDownloading: false, Error: false } && CrunchyrollManager.Instance.CrunOptions.AutoDownload && ActiveDownloads < CrunchyrollManager.Instance.CrunOptions.SimultaneousDownloads){
|
||||
downloadItem.StartDownload();
|
||||
}
|
||||
}
|
||||
|
||||
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
|
||||
}
|
||||
|
||||
|
||||
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((CrunchyEpisode)episodeL, updateHistory);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
var episode = sList.EpisodeAndLanguages.Items.First();
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(episode.SeriesId, episode.SeasonId, episode.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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").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.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").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($"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);
|
||||
|
||||
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;
|
||||
|
||||
Queue.Add(movieMeta);
|
||||
|
||||
Console.WriteLine("Added Movie to Queue");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added Movie to Queue", ToastType.Information, 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"No episode or movie found with the id: {epId}");
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue - No episode or movie found with the id: {epId}", ToastType.Error, 3));
|
||||
}
|
||||
|
||||
|
||||
public void CrAddMusicMetaToQueue(CrunchyEpMeta epMeta){
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
epMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(epMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
|
||||
}
|
||||
|
||||
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
||||
|
||||
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
|
||||
|
||||
if (musicVideo != null){
|
||||
var musicVideoMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(musicVideo);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
|
||||
}
|
||||
|
||||
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
||||
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
musicVideoMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(musicVideoMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added music video to the queue", ToastType.Information, 1));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
|
||||
|
||||
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
|
||||
|
||||
if (concert != null){
|
||||
var concertMeta = CrunchyrollManager.Instance.CrMusic.EpisodeMeta(concert);
|
||||
|
||||
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
|
||||
}
|
||||
|
||||
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
|
||||
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
|
||||
|
||||
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
concertMeta.DownloadSettings = newOptions;
|
||||
|
||||
Queue.Add(concertMeta);
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added concert to the queue", ToastType.Information, 1));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
|
||||
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.But, data.AllEpisodes, data.E);
|
||||
|
||||
var failed = false;
|
||||
var partialAdd = false;
|
||||
|
||||
|
||||
foreach (var crunchyEpMeta in selected.Values.ToList()){
|
||||
if (crunchyEpMeta.Data?.First() != 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 (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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
public void SetLimit(int newLimit){
|
||||
lock (activeProcessingJobs){
|
||||
if (newLimit == _limit) return;
|
||||
|
||||
if (newLimit > _limit){
|
||||
int giveBack = Math.Min(_borrowed, newLimit - _limit);
|
||||
if (giveBack > 0){
|
||||
activeProcessingJobs.Release(giveBack);
|
||||
_borrowed -= giveBack;
|
||||
}
|
||||
|
||||
int more = newLimit - _limit - giveBack;
|
||||
if (more > 0) activeProcessingJobs.Release(more);
|
||||
} else{
|
||||
int toPark = _limit - newLimit;
|
||||
|
||||
for (int i = 0; i < toPark; i++){
|
||||
_ = Task.Run(async () => {
|
||||
await activeProcessingJobs.WaitAsync().ConfigureAwait(false);
|
||||
Interlocked.Increment(ref _borrowed);
|
||||
OnQueueStateChanged();
|
||||
});
|
||||
}
|
||||
|
||||
public void UpdateDownloadListItems(){
|
||||
downloadItems.SyncFromQueue(queue);
|
||||
|
||||
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
|
||||
|
||||
if (crunchyrollManager.CrunOptions.AutoDownload){
|
||||
RequestPump();
|
||||
}
|
||||
}
|
||||
|
||||
_limit = newLimit;
|
||||
private void RequestPump(){
|
||||
Interlocked.Exchange(ref pumpDirty, 1);
|
||||
|
||||
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
|
||||
return;
|
||||
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
RunPump,
|
||||
Avalonia.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void RunPump(){
|
||||
try{
|
||||
while (Interlocked.Exchange(ref pumpDirty, 0) == 1){
|
||||
PumpQueue();
|
||||
}
|
||||
} finally{
|
||||
Interlocked.Exchange(ref pumpScheduled, 0);
|
||||
|
||||
if (Volatile.Read(ref pumpDirty) == 1 &&
|
||||
Interlocked.CompareExchange(ref pumpScheduled, 1, 0) == 0){
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
RunPump,
|
||||
Avalonia.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void PumpQueue(){
|
||||
if (!crunchyrollManager.CrunOptions.AutoDownload)
|
||||
return;
|
||||
|
||||
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);
|
||||
|
||||
if (freeSlots == 0)
|
||||
return;
|
||||
|
||||
foreach (var item in queue.ToList()){
|
||||
if (freeSlots == 0)
|
||||
break;
|
||||
|
||||
if (item.DownloadProgress.IsError)
|
||||
continue;
|
||||
|
||||
if (item.DownloadProgress.IsDone)
|
||||
continue;
|
||||
|
||||
if (item.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing)
|
||||
continue;
|
||||
|
||||
if (activeOrStarting.Contains(item))
|
||||
continue;
|
||||
|
||||
activeOrStarting.Add(item);
|
||||
freeSlots--;
|
||||
|
||||
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 = downloadItems.Find(item);
|
||||
if (model != null){
|
||||
_ = model.StartDownloadCore();
|
||||
} else{
|
||||
ReleaseDownloadSlot(item);
|
||||
}
|
||||
}
|
||||
|
||||
OnQueueStateChanged();
|
||||
}
|
||||
|
||||
|
||||
private void OnQueueStateChanged(){
|
||||
QueueStateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void NotifyDownloadStateChanged(){
|
||||
OnPropertyChanged(nameof(ActiveDownloads));
|
||||
OnPropertyChanged(nameof(HasActiveDownloads));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,9 +6,6 @@ using ReactiveUI.Avalonia;
|
|||
namespace CRD;
|
||||
|
||||
sealed class Program{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args){
|
||||
var isHeadless = args.Contains("--headless");
|
||||
|
|
@ -16,19 +13,12 @@ sealed class Program{
|
|||
BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
// public static AppBuilder BuildAvaloniaApp()
|
||||
// => AppBuilder.Configure<App>()
|
||||
// .UsePlatformDetect()
|
||||
// .WithInterFont()
|
||||
// .LogToTrace();
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp(bool isHeadless){
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI() ;
|
||||
.UseReactiveUI(_ => { });
|
||||
|
||||
if (isHeadless){
|
||||
Console.WriteLine("Running in headless mode...");
|
||||
|
|
|
|||
|
|
@ -60,20 +60,38 @@
|
|||
</ContentPresenter.Styles>
|
||||
</ContentPresenter>
|
||||
|
||||
<Viewbox Name="IconBox"
|
||||
<Grid Width="28"
|
||||
Height="28"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
|
||||
<Viewbox Name="IconBox"
|
||||
Stretch="Uniform">
|
||||
<ContentPresenter Name="Icon"
|
||||
Content="{Binding TemplateSettings.Icon, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Viewbox>
|
||||
|
||||
<Ellipse Name="NotificationDot"
|
||||
Width="8"
|
||||
Height="8"
|
||||
Fill="#FF3B30"
|
||||
Stroke="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
StrokeThickness="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-1,-1,0"
|
||||
IsVisible="False" />
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav.redDot uip|NavigationViewItemPresenter /template/ Ellipse#NotificationDot">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pointerover /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ namespace CRD.Utils;
|
|||
|
||||
public class AudioPlayer{
|
||||
private readonly Player _player;
|
||||
private bool _isPlaying = false;
|
||||
private bool _isPlaying;
|
||||
|
||||
public AudioPlayer(){
|
||||
_player = new Player();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Net.Http;
|
|||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
|
||||
namespace CRD.Utils.DRM;
|
||||
|
||||
|
|
@ -12,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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ public enum SeriesType{
|
|||
Artist,
|
||||
[EnumMember(Value = "Series")]
|
||||
Series,
|
||||
[EnumMember(Value = "Movie")]
|
||||
Movie,
|
||||
[EnumMember(Value = "Unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
|
@ -276,10 +278,28 @@ public enum EpisodeDownloadMode{
|
|||
OnlySubs,
|
||||
}
|
||||
|
||||
public enum DownloadState{
|
||||
Queued,
|
||||
Downloading,
|
||||
Paused,
|
||||
Processing,
|
||||
Done,
|
||||
Error
|
||||
}
|
||||
|
||||
public enum HistoryRefreshMode{
|
||||
DefaultAll = 0,
|
||||
DefaultActive = 1,
|
||||
FastNewReleases = 50
|
||||
}
|
||||
|
||||
public enum SonarrCoverType{
|
||||
Unknown,
|
||||
Poster,
|
||||
Banner,
|
||||
FanArt,
|
||||
Poster,
|
||||
Screenshot,
|
||||
Headshot,
|
||||
ClearLogo,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using Newtonsoft.Json;
|
||||
|
|
@ -15,6 +17,7 @@ using Newtonsoft.Json;
|
|||
namespace CRD.Utils.HLS;
|
||||
|
||||
public class HlsDownloader{
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private Data _data = new();
|
||||
|
||||
private CrunchyEpMeta _currentEpMeta;
|
||||
|
|
@ -22,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;
|
||||
|
|
@ -60,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){
|
||||
|
|
@ -139,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}");
|
||||
|
|
@ -183,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;
|
||||
|
|
@ -198,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){
|
||||
|
|
@ -232,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;
|
||||
|
|
@ -240,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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -266,11 +282,16 @@ public class HlsDownloader{
|
|||
? $"{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,
|
||||
|
|
@ -278,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)){
|
||||
|
|
@ -293,26 +314,124 @@ public class HlsDownloader{
|
|||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
while (_currentEpMeta.Paused){
|
||||
await Task.Delay(500);
|
||||
await WaitWhilePausedAsync(_cancellationToken);
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
return (Ok: false, _data.Parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (Ok: true, _data.Parts);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -326,6 +445,7 @@ public class HlsDownloader{
|
|||
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
|
||||
mergedParts = (int?)resumeData?.MergedParts ?? 0;
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -334,13 +454,18 @@ 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;
|
||||
|
||||
void CleanupBufferedArtifacts(bool cleanAll = true){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, cleanAll);
|
||||
}
|
||||
|
||||
try{
|
||||
for (int i = 0; i < segments.Count; i++){
|
||||
try{
|
||||
await semaphore.WaitAsync(token);
|
||||
|
|
@ -356,111 +481,49 @@ public class HlsDownloader{
|
|||
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));
|
||||
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)){
|
||||
if (!_currentEpMeta.DownloadProgress.Done){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
||||
}
|
||||
CleanupBufferedArtifacts();
|
||||
} else{
|
||||
Console.Error.WriteLine("Download cancelled due to error.");
|
||||
CleanupBufferedArtifacts(false);
|
||||
}
|
||||
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
} finally{
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
if (errorOccurred)
|
||||
if (Volatile.Read(ref errorOccurred) == 1){
|
||||
CleanupBufferedArtifacts(false);
|
||||
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++;
|
||||
|
||||
|
|
@ -473,19 +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" : "")
|
||||
};
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
if (!_currentEpMeta.DownloadProgress.Done){
|
||||
CleanupNewDownloadMethod(tempDir, resumeFile, true);
|
||||
}
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
|
||||
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
|
||||
CleanupBufferedArtifacts();
|
||||
return (false, _data.Parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -499,14 +567,7 @@ 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
|
||||
|
|
@ -561,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;
|
||||
|
|
@ -568,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 != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), 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);
|
||||
|
|
@ -579,7 +641,7 @@ public class HlsDownloader{
|
|||
Interlocked.Add(ref _data.TotalBytes, dec.Length);
|
||||
}
|
||||
} else{
|
||||
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), 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);
|
||||
|
|
@ -642,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, new Dictionary<string, string>(), 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.");
|
||||
}
|
||||
|
|
@ -658,7 +720,8 @@ public class HlsDownloader{
|
|||
return _data.Keys[kUri];
|
||||
}
|
||||
|
||||
public async Task<byte[]?> GetData(int partIndex, string uri, IDictionary<string, string> headers, 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;
|
||||
|
|
@ -667,8 +730,8 @@ public class HlsDownloader{
|
|||
|
||||
// Setup request headers
|
||||
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
foreach (var header in headers){
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
if (byteRange != null){
|
||||
request.Headers.Range = new RangeHeaderValue(byteRange.Offset, byteRange.Offset + byteRange.Length - 1);
|
||||
}
|
||||
|
||||
// Set default user-agent if not provided
|
||||
|
|
@ -676,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";
|
||||
|
|
@ -696,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()}");
|
||||
|
|
@ -709,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();
|
||||
|
|
@ -753,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();
|
||||
|
|
@ -797,13 +861,6 @@ public class Key{
|
|||
public class ByteRange{
|
||||
public long Offset{ get; set; }
|
||||
public long Length{ get; set; }
|
||||
|
||||
public IDictionary<string, string> ToDictionary(){
|
||||
return new Dictionary<string, string>{
|
||||
{ "Offset", Offset.ToString() },
|
||||
{ "Length", Length.ToString() }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class HlsOptions{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using System.Linq;
|
|||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -19,16 +20,43 @@ using CRD.Downloader;
|
|||
using CRD.Utils.Ffmpeg_Encoding;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.HLS;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.JsonConv;
|
||||
using CRD.Utils.Parser;
|
||||
using CRD.Utils.Structs;
|
||||
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();
|
||||
|
|
@ -59,7 +87,7 @@ public class Helpers{
|
|||
return clone;
|
||||
}
|
||||
|
||||
public static T DeepCopy<T>(T obj){
|
||||
public static T? DeepCopy<T>(T obj){
|
||||
var settings = new JsonSerializerSettings{
|
||||
ContractResolver = new DefaultContractResolver{
|
||||
IgnoreSerializableAttribute = true,
|
||||
|
|
@ -190,80 +218,7 @@ public class Helpers{
|
|||
}
|
||||
}
|
||||
|
||||
public static Locale ConvertStringToLocale(string? value){
|
||||
foreach (Locale locale in Enum.GetValues(typeof(Locale))){
|
||||
var type = typeof(Locale);
|
||||
var memInfo = type.GetMember(locale.ToString());
|
||||
var attributes = memInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false);
|
||||
var description = ((EnumMemberAttribute)attributes[0]).Value;
|
||||
|
||||
if (description == value){
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(value)){
|
||||
return Locale.DefaulT;
|
||||
}
|
||||
|
||||
return Locale.Unknown; // Return default if not found
|
||||
}
|
||||
|
||||
public static string GenerateSessionId(){
|
||||
// Get UTC milliseconds
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var milliseconds = utcNow.Millisecond.ToString().PadLeft(3, '0');
|
||||
|
||||
// Get a high-resolution timestamp
|
||||
long timestamp = Stopwatch.GetTimestamp();
|
||||
double timestampToMilliseconds = (double)timestamp / Stopwatch.Frequency * 1000;
|
||||
string highResTimestamp = timestampToMilliseconds.ToString("F0").PadLeft(13, '0');
|
||||
|
||||
return milliseconds + highResTimestamp;
|
||||
}
|
||||
|
||||
public static void ConvertChapterFileForFFMPEG(string chapterFilePath){
|
||||
var chapterLines = File.ReadAllLines(chapterFilePath);
|
||||
var ffmpegChapterLines = new List<string>{ ";FFMETADATA1" };
|
||||
var chapters = new List<(double StartTime, string Title)>();
|
||||
|
||||
for (int i = 0; i < chapterLines.Length; i += 2){
|
||||
var timeLine = chapterLines[i];
|
||||
var nameLine = chapterLines[i + 1];
|
||||
|
||||
var timeParts = timeLine.Split('=');
|
||||
var nameParts = nameLine.Split('=');
|
||||
|
||||
if (timeParts.Length == 2 && nameParts.Length == 2){
|
||||
var startTime = TimeSpan.Parse(timeParts[1]).TotalMilliseconds;
|
||||
var title = nameParts[1];
|
||||
chapters.Add((startTime, title));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chapters by start time
|
||||
chapters = chapters.OrderBy(c => c.StartTime).ToList();
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++){
|
||||
var startTime = chapters[i].StartTime;
|
||||
var title = chapters[i].Title;
|
||||
var endTime = (i + 1 < chapters.Count) ? chapters[i + 1].StartTime : startTime + 10000; // Add 10 seconds to the last chapter end time
|
||||
|
||||
if (endTime < startTime){
|
||||
endTime = startTime + 10000; // Correct end time if it is before start time
|
||||
}
|
||||
|
||||
ffmpegChapterLines.Add("[CHAPTER]");
|
||||
ffmpegChapterLines.Add("TIMEBASE=1/1000");
|
||||
ffmpegChapterLines.Add($"START={startTime}");
|
||||
ffmpegChapterLines.Add($"END={endTime}");
|
||||
ffmpegChapterLines.Add($"title={title}");
|
||||
}
|
||||
|
||||
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
|
||||
}
|
||||
|
||||
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string type, 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;
|
||||
|
|
@ -294,7 +249,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;
|
||||
|
||||
|
|
@ -306,211 +271,318 @@ 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;
|
||||
}
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++){
|
||||
try{
|
||||
if (File.Exists(filePath)){
|
||||
File.Delete(filePath);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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){
|
||||
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? process = null;
|
||||
DataReceivedEventHandler? outputHandler = null;
|
||||
DataReceivedEventHandler? errorHandler = null;
|
||||
|
||||
process.OutputDataReceived += (sender, e) => {
|
||||
try{
|
||||
process = new Process{
|
||||
StartInfo = new ProcessStartInfo{
|
||||
WorkingDirectory = workingDir,
|
||||
FileName = bin,
|
||||
Arguments = command,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
},
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
outputHandler = (_, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.WriteLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) => {
|
||||
errorHandler = (_, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.Error.WriteLine($"{e.Data}");
|
||||
Console.Error.WriteLine(e.Data);
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += outputHandler;
|
||||
process.ErrorDataReceived += errorHandler;
|
||||
|
||||
process.Start();
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
process.WaitForExit();
|
||||
|
||||
bool isSuccess = process.ExitCode == 0;
|
||||
|
||||
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 string GetQualityOption(VideoPreset preset){
|
||||
private 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}", // For NVENC
|
||||
"h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV
|
||||
"h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE
|
||||
_ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265
|
||||
"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" or "hevc_amf"
|
||||
=> preset.Crf is >= 0 and <= 51 ? ["-qp", q] : [],
|
||||
|
||||
_ // libx264/libx265/etc.
|
||||
=> preset.Crf >= 0 ? ["-crf", q] : []
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(string inputFilePath, VideoPreset preset, CrunchyEpMeta? data = null){
|
||||
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
|
||||
string inputFilePath,
|
||||
VideoPreset preset,
|
||||
CrunchyEpMeta? data = null){
|
||||
try{
|
||||
string outputExtension = Path.GetExtension(inputFilePath);
|
||||
string directory = Path.GetDirectoryName(inputFilePath);
|
||||
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath);
|
||||
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
|
||||
string ext = Path.GetExtension(inputFilePath);
|
||||
string dir = Path.GetDirectoryName(inputFilePath)!;
|
||||
string name = Path.GetFileNameWithoutExtension(inputFilePath);
|
||||
|
||||
string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => {
|
||||
var splitIndex = param.IndexOf(' ');
|
||||
if (splitIndex > 0){
|
||||
var prefix = param[..splitIndex];
|
||||
var value = param[(splitIndex + 1)..];
|
||||
|
||||
if (value.Contains(' ') && !(value.StartsWith("\"") && value.EndsWith("\""))){
|
||||
value = $"\"{value}\"";
|
||||
}
|
||||
|
||||
return $"{prefix} {value}";
|
||||
}
|
||||
|
||||
return param;
|
||||
}));
|
||||
|
||||
string qualityOption = GetQualityOption(preset);
|
||||
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
|
||||
|
||||
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
|
||||
if (totalDuration == null){
|
||||
Console.Error.WriteLine("Unable to retrieve input file duration.");
|
||||
|
||||
var args = new List<string>{
|
||||
"-nostdin",
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", inputFilePath,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(preset.Codec)){
|
||||
args.Add("-c:v");
|
||||
args.Add(preset.Codec);
|
||||
|
||||
args.AddRange(GetQualityOption(preset));
|
||||
|
||||
args.Add("-vf");
|
||||
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
|
||||
}
|
||||
|
||||
|
||||
foreach (var param in preset.AdditionalParameters){
|
||||
args.AddRange(SplitArguments(param));
|
||||
}
|
||||
|
||||
args.Add(tempOutput);
|
||||
|
||||
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
|
||||
int exitCode;
|
||||
try{
|
||||
exitCode = await RunFFmpegAsync(
|
||||
CfgManager.PathFFMPEG,
|
||||
args,
|
||||
data?.Cts.Token ?? CancellationToken.None,
|
||||
onStdErr: line => { Console.Error.WriteLine(line); },
|
||||
onStdOut: Console.WriteLine
|
||||
);
|
||||
} catch (OperationCanceledException){
|
||||
if (File.Exists(tempOutput)){
|
||||
try{
|
||||
File.Delete(tempOutput);
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("FFMPEG task was canceled");
|
||||
return (false, -2);
|
||||
}
|
||||
|
||||
bool success = exitCode == 0;
|
||||
|
||||
if (success){
|
||||
File.Delete(inputFilePath);
|
||||
File.Move(tempOutput, inputFilePath);
|
||||
} else{
|
||||
Console.WriteLine($"Total Duration: {totalDuration}");
|
||||
if (File.Exists(tempOutput)){
|
||||
File.Delete(tempOutput);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("FFmpeg processing failed.");
|
||||
Console.Error.WriteLine("Command:");
|
||||
Console.Error.WriteLine(commandString);
|
||||
}
|
||||
|
||||
|
||||
string ffmpegCommand = $"-loglevel info -i \"{inputFilePath}\" -c:v {preset.Codec} {qualityOption} -vf \"scale={preset.Resolution},fps={preset.FrameRate}\" {additionalParams} \"{tempOutputFilePath}\"";
|
||||
using (var process = new Process()){
|
||||
process.StartInfo.FileName = CfgManager.PathFFMPEG;
|
||||
process.StartInfo.Arguments = ffmpegCommand;
|
||||
process.StartInfo.RedirectStandardOutput = true;
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
process.StartInfo.UseShellExecute = false;
|
||||
process.StartInfo.CreateNoWindow = true;
|
||||
process.EnableRaisingEvents = true;
|
||||
return (success, exitCode);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine(ex);
|
||||
|
||||
process.OutputDataReceived += (sender, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.WriteLine(e.Data);
|
||||
return (false, -1);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitArguments(string commandLine){
|
||||
var args = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
bool inQuotes = false;
|
||||
|
||||
foreach (char c in commandLine){
|
||||
if (c == '"'){
|
||||
inQuotes = !inQuotes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(c) && !inQuotes){
|
||||
if (current.Length > 0){
|
||||
args.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
} else{
|
||||
current.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
args.Add(current.ToString());
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private static string BuildCommandString(string exe, IEnumerable<string> args){
|
||||
static string Quote(string s){
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return "\"\"";
|
||||
|
||||
return s.Contains(' ') || s.Contains('"')
|
||||
? $"\"{s.Replace("\"", "\\\"")}\""
|
||||
: s;
|
||||
}
|
||||
|
||||
return exe + " " + string.Join(" ", args.Select(Quote));
|
||||
}
|
||||
|
||||
public static async Task<int> RunFFmpegAsync(
|
||||
string ffmpegPath,
|
||||
IEnumerable<string> args,
|
||||
CancellationToken token,
|
||||
Action<string>? onStdErr = null,
|
||||
Action<string>? onStdOut = null){
|
||||
using var process = new Process();
|
||||
|
||||
process.StartInfo = new ProcessStartInfo{
|
||||
FileName = ffmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
process.ErrorDataReceived += (sender, e) => {
|
||||
if (!string.IsNullOrEmpty(e.Data)){
|
||||
Console.Error.WriteLine($"{e.Data}");
|
||||
if (data != null && totalDuration != null){
|
||||
ParseProgress(e.Data, totalDuration.Value, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
foreach (var arg in args)
|
||||
process.StartInfo.ArgumentList.Add(arg);
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var reg = data?.Cts.Token.Register(() => {
|
||||
// capture streams instead of process
|
||||
var stdout = process.StandardOutput;
|
||||
var stderr = process.StandardError;
|
||||
|
||||
async Task ReadStreamAsync(StreamReader reader, Action<string>? callback){
|
||||
while (await reader.ReadLineAsync(token) is{ } line)
|
||||
callback?.Invoke(line);
|
||||
}
|
||||
|
||||
var stdoutTask = ReadStreamAsync(stdout, onStdOut);
|
||||
var stderrTask = ReadStreamAsync(stderr, onStdErr);
|
||||
|
||||
var proc = process;
|
||||
|
||||
await using var reg = token.Register(() => {
|
||||
try{
|
||||
if (!process.HasExited)
|
||||
process.Kill(true);
|
||||
proc.Kill(true);
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
|
||||
try{
|
||||
await process.WaitForExitAsync(data.Cts.Token);
|
||||
await process.WaitForExitAsync(token);
|
||||
} catch (OperationCanceledException){
|
||||
if (File.Exists(tempOutputFilePath)){
|
||||
try{
|
||||
File.Delete(tempOutputFilePath);
|
||||
if (!process.HasExited)
|
||||
process.Kill(true);
|
||||
} catch{
|
||||
// ignored
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return (IsOk: false, ErrorCode: -2);
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
|
||||
return process.ExitCode;
|
||||
}
|
||||
|
||||
bool isSuccess = process.ExitCode == 0;
|
||||
|
||||
if (isSuccess){
|
||||
// Delete the original input file
|
||||
File.Delete(inputFilePath);
|
||||
|
||||
// Rename the output file to the original name
|
||||
File.Move(tempOutputFilePath, inputFilePath);
|
||||
} else{
|
||||
// If something went wrong, delete the temporary output file
|
||||
if (File.Exists(tempOutputFilePath)){
|
||||
try{
|
||||
File.Delete(tempOutputFilePath);
|
||||
} catch{
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("FFmpeg processing failed.");
|
||||
Console.Error.WriteLine($"Command: {ffmpegCommand}");
|
||||
}
|
||||
|
||||
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
||||
return (IsOk: false, ErrorCode: -1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){
|
||||
try{
|
||||
if (progressString.Contains("time=")){
|
||||
var timeIndex = progressString.IndexOf("time=") + 5;
|
||||
var timeString = progressString.Substring(timeIndex, 11);
|
||||
|
||||
|
||||
if (TimeSpan.TryParse(timeString, out var currentTime)){
|
||||
int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100);
|
||||
Console.WriteLine($"Progress: {progress:F2}%");
|
||||
|
||||
data.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = true,
|
||||
Percent = progress,
|
||||
Time = 0,
|
||||
DownloadSpeedBytes = 0,
|
||||
Doing = "Encoding"
|
||||
};
|
||||
|
||||
QueueManager.Instance.Queue.Refresh();
|
||||
}
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine("Failed to calculate encoding progess");
|
||||
Console.Error.WriteLine(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){
|
||||
try{
|
||||
|
|
@ -911,6 +983,8 @@ public class Helpers{
|
|||
if (result == ContentDialogResult.Primary){
|
||||
timer.Stop();
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
} finally{
|
||||
ShutdownLock.Release();
|
||||
}
|
||||
|
|
@ -967,4 +1041,22 @@ public class Helpers{
|
|||
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ExecuteFile(string filePath){
|
||||
try{
|
||||
if (Path.GetExtension(filePath).Equals(".ps1", StringComparison.OrdinalIgnoreCase)){
|
||||
Process.Start("powershell.exe", $"-ExecutionPolicy Bypass -File \"{filePath}\"");
|
||||
} else{
|
||||
Process.Start(new ProcessStartInfo{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Execution failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
|
|
@ -7,35 +8,52 @@ using System.Threading.Tasks;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils;
|
||||
namespace CRD.Utils.Http;
|
||||
|
||||
public class FlareSolverrClient{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
private FlareSolverrProperties properties;
|
||||
private FlareSolverrProperties? flareProperties;
|
||||
private readonly MitmProxyProperties? mitmProperties;
|
||||
|
||||
private string flaresolverrUrl = "http://localhost:8191";
|
||||
private readonly string mitmProxyUrl = "localhost:8080";
|
||||
|
||||
private const string HeaderToken = "$$headers[]";
|
||||
private const string PostToken = "$$post";
|
||||
|
||||
public FlareSolverrClient(){
|
||||
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null) properties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
|
||||
flareProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
|
||||
mitmProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrMitmProperties;
|
||||
|
||||
if (properties != null){
|
||||
flaresolverrUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}";
|
||||
if (flareProperties != null){
|
||||
flaresolverrUrl = $"http{(flareProperties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(flareProperties.Host) ? flareProperties.Host : "localhost")}:{flareProperties.Port}";
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient{ BaseAddress = new Uri(flaresolverrUrl) };
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
|
||||
if (mitmProperties != null){
|
||||
mitmProxyUrl =
|
||||
$"{(!string.IsNullOrWhiteSpace(mitmProperties.Host) ? mitmProperties.Host : "localhost")}:" +
|
||||
$"{mitmProperties.Port}";
|
||||
}
|
||||
|
||||
httpClient = new HttpClient{ BaseAddress = new Uri(flaresolverrUrl) };
|
||||
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
|
||||
}
|
||||
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent, List<Cookie> cookies)> SendViaFlareSolverrAsync(HttpRequestMessage request,List<Cookie> cookiesToSend){
|
||||
public Task<(bool IsOk, string ResponseContent, List<Cookie> Cookies, string UserAgent)> SendViaProxySolverAsync(
|
||||
HttpRequestMessage request,
|
||||
List<Cookie> cookiesToSend){
|
||||
return mitmProperties is{ UseMitmProxy: true }
|
||||
? SendViaMitmProxyAsync(request, cookiesToSend)
|
||||
: SendViaFlareSolverrAsync(request, cookiesToSend);
|
||||
}
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent, List<Cookie> cookies, string UserAgent)> SendViaFlareSolverrAsync(HttpRequestMessage request, List<Cookie> cookiesToSend){
|
||||
var flaresolverrCookies = new List<object>();
|
||||
|
||||
foreach (var cookie in cookiesToSend)
|
||||
{
|
||||
flaresolverrCookies.Add(new
|
||||
{
|
||||
foreach (var cookie in cookiesToSend){
|
||||
flaresolverrCookies.Add(new{
|
||||
name = cookie.Name,
|
||||
value = cookie.Value,
|
||||
domain = cookie.Domain,
|
||||
|
|
@ -71,10 +89,10 @@ public class FlareSolverrClient{
|
|||
|
||||
HttpResponseMessage flareSolverrResponse;
|
||||
try{
|
||||
flareSolverrResponse = await _httpClient.SendAsync(flareSolverrRequest);
|
||||
flareSolverrResponse = await httpClient.SendAsync(flareSolverrRequest);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error sending request to FlareSolverr: {ex.Message}");
|
||||
return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", []);
|
||||
return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", [], string.Empty);
|
||||
}
|
||||
|
||||
string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync();
|
||||
|
|
@ -83,10 +101,10 @@ public class FlareSolverrClient{
|
|||
var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent);
|
||||
|
||||
if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){
|
||||
return (IsOk: true, ResponseContent: flareSolverrResult.Solution.Response, flareSolverrResult.Solution.cookies);
|
||||
return (IsOk: true, ResponseContent: flareSolverrResult.Solution?.Response ?? string.Empty, flareSolverrResult.Solution?.Cookies ?? [], flareSolverrResult.Solution?.UserAgent ?? string.Empty);
|
||||
} else{
|
||||
Console.Error.WriteLine($"Flare Solverr Failed \n Response: {flareSolverrResponseContent}");
|
||||
return (IsOk: false, ResponseContent: flareSolverrResponseContent, []);
|
||||
return (IsOk: false, ResponseContent: flareSolverrResponseContent, [], string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,23 +133,179 @@ public class FlareSolverrClient{
|
|||
|
||||
return cookiesDictionary;
|
||||
}
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent, List<Cookie> Cookies, string UserAgent)> SendViaMitmProxyAsync(
|
||||
HttpRequestMessage request,
|
||||
List<Cookie> cookiesToSend){
|
||||
if (request.RequestUri == null){
|
||||
return (false, "RequestUri is null.", [], "");
|
||||
}
|
||||
|
||||
var flaresolverrCookies = cookiesToSend.Select(cookie => new{
|
||||
name = cookie.Name,
|
||||
value = cookie.Value,
|
||||
domain = cookie.Domain,
|
||||
path = cookie.Path,
|
||||
secure = cookie.Secure,
|
||||
httpOnly = cookie.HttpOnly
|
||||
}).ToList();
|
||||
|
||||
string proxiedUrl = BuildMitmUrl(request);
|
||||
string? postData = await BuildPostDataAsync(request);
|
||||
|
||||
var requestData = new{
|
||||
cmd = request.Method.Method.ToLowerInvariant() switch{
|
||||
"get" => "request.get",
|
||||
"post" => "request.post",
|
||||
"patch" => "request.patch",
|
||||
_ => "request.get"
|
||||
},
|
||||
url = proxiedUrl,
|
||||
maxTimeout = 60000,
|
||||
postData,
|
||||
cookies = flaresolverrCookies,
|
||||
proxy = new{
|
||||
url = mitmProxyUrl
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(requestData);
|
||||
var flareSolverrContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var flareSolverrRequest = new HttpRequestMessage(HttpMethod.Post, $"{flaresolverrUrl}/v1"){
|
||||
Content = flareSolverrContent
|
||||
};
|
||||
|
||||
HttpResponseMessage flareSolverrResponse;
|
||||
try{
|
||||
flareSolverrResponse = await httpClient.SendAsync(flareSolverrRequest);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error sending request to FlareSolverr: {ex.Message}");
|
||||
return (false, $"Error sending request to FlareSolverr: {ex.Message}", [], "");
|
||||
}
|
||||
|
||||
string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync();
|
||||
var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent);
|
||||
|
||||
if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){
|
||||
return (
|
||||
true,
|
||||
flareSolverrResult.Solution?.Response ?? string.Empty,
|
||||
flareSolverrResult.Solution?.Cookies ?? [],
|
||||
flareSolverrResult.Solution?.UserAgent ?? string.Empty
|
||||
);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"FlareSolverr MITM failed\nResponse: {flareSolverrResponseContent}");
|
||||
return (false, flareSolverrResponseContent, [], "");
|
||||
}
|
||||
|
||||
private string BuildMitmUrl(HttpRequestMessage request){
|
||||
if (request.RequestUri == null){
|
||||
throw new InvalidOperationException("RequestUri is null.");
|
||||
}
|
||||
|
||||
var uri = request.RequestUri;
|
||||
var parts = new List<string>();
|
||||
|
||||
string existingQuery = uri.Query;
|
||||
if (!string.IsNullOrWhiteSpace(existingQuery)){
|
||||
parts.Add(existingQuery.TrimStart('?'));
|
||||
}
|
||||
|
||||
foreach (var header in GetHeaders(request)){
|
||||
// Skip headers that should not be forwarded this way
|
||||
if (header.Key.Equals("Host", StringComparison.OrdinalIgnoreCase) ||
|
||||
header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) ||
|
||||
header.Key.Equals("Cookie", StringComparison.OrdinalIgnoreCase)){
|
||||
continue;
|
||||
}
|
||||
|
||||
string headerValue = $"{header.Key}:{header.Value}";
|
||||
parts.Add($"{Uri.EscapeDataString(HeaderToken)}={Uri.EscapeDataString(headerValue)}");
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(uri){
|
||||
Query = string.Join("&", parts.Where(p => !string.IsNullOrWhiteSpace(p)))
|
||||
};
|
||||
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
private async Task<string?> BuildPostDataAsync(HttpRequestMessage request){
|
||||
if (request.Content == null){
|
||||
return null;
|
||||
}
|
||||
|
||||
string body = await request.Content.ReadAsStringAsync();
|
||||
if (string.IsNullOrEmpty(body)){
|
||||
return null;
|
||||
}
|
||||
|
||||
string? mediaType = request.Content.Headers.ContentType?.MediaType;
|
||||
|
||||
// The MITM proxy understands $$post=<base64> for JSON/text POST payloads.
|
||||
if (request.Method == HttpMethod.Post &&
|
||||
!string.IsNullOrWhiteSpace(mediaType) &&
|
||||
mediaType.Contains("json", StringComparison.OrdinalIgnoreCase)){
|
||||
string base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(body));
|
||||
return $"{PostToken}={base64}";
|
||||
}
|
||||
|
||||
// Fallback: send raw post body
|
||||
return body;
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> GetHeaders(HttpRequestMessage request){
|
||||
foreach (var header in request.Headers){
|
||||
yield return new KeyValuePair<string, string>(
|
||||
header.Key,
|
||||
string.Join(", ", header.Value));
|
||||
}
|
||||
|
||||
if (request.Content != null){
|
||||
foreach (var header in request.Content.Headers){
|
||||
yield return new KeyValuePair<string, string>(
|
||||
header.Key,
|
||||
string.Join(", ", header.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class FlareSolverrResponse{
|
||||
public string Status{ get; set; }
|
||||
public FlareSolverrSolution Solution{ get; set; }
|
||||
public string? Status{ get; set; }
|
||||
public FlareSolverrSolution? Solution{ get; set; }
|
||||
}
|
||||
|
||||
public class FlareSolverrSolution{
|
||||
public string Url{ get; set; }
|
||||
public string Status{ get; set; }
|
||||
public List<Cookie> cookies{ get; set; }
|
||||
public string Response{ get; set; }
|
||||
[JsonProperty("url")]
|
||||
public string? Url{ get; set; }
|
||||
|
||||
[JsonProperty("status")]
|
||||
public string? Status{ get; set; }
|
||||
|
||||
[JsonProperty("cookies")]
|
||||
public List<Cookie> Cookies{ get; set; } = [];
|
||||
|
||||
[JsonProperty("response")]
|
||||
public string? Response{ get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("userAgent")]
|
||||
public string UserAgent{ get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class FlareSolverrProperties(){
|
||||
public bool UseFlareSolverr{ get; set; }
|
||||
public string? Host{ get; set; }
|
||||
public string? Host{ get; set; } = "localhost";
|
||||
public int Port{ get; set; }
|
||||
public bool UseSsl{ get; set; }
|
||||
}
|
||||
|
||||
public class MitmProxyProperties{
|
||||
public bool UseMitmProxy{ get; set; }
|
||||
public string? Host{ get; set; } = "localhost";
|
||||
public int Port{ get; set; } = 8080;
|
||||
|
||||
public bool UseSsl{ get; set; }
|
||||
}
|
||||
|
|
@ -1,48 +1,30 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
|
||||
namespace CRD.Utils;
|
||||
namespace CRD.Utils.Http;
|
||||
|
||||
public class HttpClientReq{
|
||||
#region Singelton
|
||||
|
||||
private static HttpClientReq? instance;
|
||||
private static readonly object padlock = new object();
|
||||
|
||||
public static HttpClientReq Instance{
|
||||
get{
|
||||
if (instance == null){
|
||||
lock (padlock){
|
||||
if (instance == null){
|
||||
instance = new HttpClientReq();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
public static HttpClientReq Instance{ get; } = new();
|
||||
|
||||
private HttpClient client;
|
||||
|
||||
public readonly bool useFlareSolverr;
|
||||
private FlareSolverrClient flareSolverrClient;
|
||||
public readonly bool UseFlareSolverr;
|
||||
private FlareSolverrClient? flareSolverrClient;
|
||||
|
||||
public HttpClientReq(){
|
||||
IWebProxy systemProxy = WebRequest.DefaultWebProxy;
|
||||
IWebProxy? systemProxy = WebRequest.DefaultWebProxy;
|
||||
|
||||
HttpClientHandler handler = new HttpClientHandler();
|
||||
HttpClientHandler handler;
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.ProxyEnabled && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.ProxyHost)){
|
||||
handler = CreateHandler(true, CrunchyrollManager.Instance.CrunOptions.ProxySocks, CrunchyrollManager.Instance.CrunOptions.ProxyHost, CrunchyrollManager.Instance.CrunOptions.ProxyPort,
|
||||
|
|
@ -74,7 +56,7 @@ public class HttpClientReq{
|
|||
}
|
||||
|
||||
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
|
||||
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Crunchyroll/1.9.0 Nintendo Switch/18.1.0.0 UE4/4.27");
|
||||
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Crunchyroll/3.60.0 Android/9 okhttp/4.12.0");
|
||||
|
||||
|
|
@ -84,7 +66,7 @@ public class HttpClientReq{
|
|||
client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive");
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null && CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties.UseFlareSolverr){
|
||||
useFlareSolverr = true;
|
||||
UseFlareSolverr = true;
|
||||
flareSolverrClient = new FlareSolverrClient();
|
||||
}
|
||||
}
|
||||
|
|
@ -130,15 +112,37 @@ 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)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
|
||||
bool allowChallengeBypass = true){
|
||||
string content = string.Empty;
|
||||
try{
|
||||
if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
|
||||
AttachCookies(request, cookieStore);
|
||||
}
|
||||
|
||||
var retryRequest = await CloneHttpRequestMessageAsync(request);
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
if (ChallengeDetector.IsClearanceRequired(response)){
|
||||
Console.Error.WriteLine($" Cloudflare Challenge detected");
|
||||
if (allowChallengeBypass && ChallengeDetector.IsClearanceRequired(response)){
|
||||
Console.Error.WriteLine($"Cloudflare Challenge detected");
|
||||
if (UseFlareSolverr && flareSolverrClient != null){
|
||||
var solverResult = await flareSolverrClient.SendViaProxySolverAsync(
|
||||
retryRequest, GetCookiesForRequest(cookieStore));
|
||||
|
||||
if (!solverResult.IsOk){
|
||||
return (false, solverResult.ResponseContent, "Challenge bypass failed");
|
||||
}
|
||||
|
||||
// foreach (var cookie in solverResult.Cookies){
|
||||
// if(cookie.Name == "__cf_bm")continue;
|
||||
// AddCookie(cookie.Domain, cookie, cookieStore);
|
||||
// }
|
||||
|
||||
return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "");
|
||||
}
|
||||
|
||||
return (false, content, "Cloudflare challenge detected");
|
||||
}
|
||||
|
||||
content = await response.Content.ReadAsStringAsync();
|
||||
|
|
@ -149,16 +153,16 @@ public class HttpClientReq{
|
|||
|
||||
return (IsOk: true, ResponseContent: content, error: "");
|
||||
} catch (Exception e){
|
||||
// Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
|
||||
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: e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
string content = string.Empty;
|
||||
try{
|
||||
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
|
||||
|
|
@ -166,7 +170,7 @@ public class HttpClientReq{
|
|||
|
||||
content = flareSolverrResponses.ResponseContent;
|
||||
|
||||
return (IsOk: 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")}");
|
||||
|
|
@ -235,7 +239,7 @@ public class HttpClientReq{
|
|||
}
|
||||
|
||||
if (cookieStore.TryGetValue(domain, out var cookies)){
|
||||
var cookie = cookies.Cast<Cookie>().FirstOrDefault(c => c.Name == cookieName);
|
||||
var cookie = cookies.FirstOrDefault(c => c.Name == cookieName);
|
||||
return cookie?.Value;
|
||||
}
|
||||
|
||||
|
|
@ -260,6 +264,71 @@ public class HttpClientReq{
|
|||
cookieStore[domain].Add(cookie);
|
||||
}
|
||||
|
||||
private static string ExtractJsonFromBrowserHtml(string responseContent){
|
||||
if (string.IsNullOrWhiteSpace(responseContent)){
|
||||
return responseContent;
|
||||
}
|
||||
|
||||
var match = Regex.Match(
|
||||
responseContent,
|
||||
@"<pre[^>]*>(.*?)</pre>",
|
||||
RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
if (!match.Success){
|
||||
return responseContent;
|
||||
}
|
||||
|
||||
return WebUtility.HtmlDecode(match.Groups[1].Value).Trim();
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage request){
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri){
|
||||
Version = request.Version,
|
||||
VersionPolicy = request.VersionPolicy
|
||||
};
|
||||
|
||||
foreach (var header in request.Headers){
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (request.Content != null){
|
||||
var contentBytes = await request.Content.ReadAsByteArrayAsync();
|
||||
var newContent = new ByteArrayContent(contentBytes);
|
||||
|
||||
foreach (var header in request.Content.Headers){
|
||||
newContent.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
clone.Content = newContent;
|
||||
}
|
||||
|
||||
foreach (var option in request.Options){
|
||||
clone.Options.Set(new HttpRequestOptionsKey<object?>(option.Key), option.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private List<Cookie> GetCookiesForRequest(Dictionary<string, CookieCollection>? cookieStore){
|
||||
var result = new List<Cookie>();
|
||||
|
||||
if (cookieStore == null){
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var entry in cookieStore){
|
||||
var cookies = entry.Value;
|
||||
|
||||
foreach (Cookie cookie in cookies){
|
||||
if (cookie.Domain == ".crunchyroll.com"){
|
||||
result.Add(cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public static HttpRequestMessage CreateRequestMessage(string uri, HttpMethod requestMethod, bool authHeader, string? accessToken = "", NameValueCollection? query = null){
|
||||
if (string.IsNullOrEmpty(uri)){
|
||||
|
|
@ -296,7 +365,7 @@ public static class ApiUrls{
|
|||
|
||||
public static string Auth => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/auth/v1/token";
|
||||
public static string Profile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/profile";
|
||||
public static string Profiles => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
|
||||
public static string MultiProfile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
|
||||
public static string CmsToken => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/index/v2";
|
||||
public static string Search => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/search";
|
||||
public static string Browse => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/browse";
|
||||
|
|
|
|||
32
CRD/Utils/Muxing/Commands/CommandBuilder.cs
Normal file
32
CRD/Utils/Muxing/Commands/CommandBuilder.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Collections.Generic;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Commands;
|
||||
|
||||
public abstract class CommandBuilder{
|
||||
private protected readonly MergerOptions Options;
|
||||
private protected readonly List<string> Args = new();
|
||||
|
||||
public CommandBuilder(MergerOptions options){
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public abstract string Build();
|
||||
|
||||
private protected void Add(string arg){
|
||||
Args.Add(arg);
|
||||
}
|
||||
|
||||
private protected void AddIf(bool condition, string arg){
|
||||
if (condition)
|
||||
Add(arg);
|
||||
}
|
||||
|
||||
private protected void AddInput(string path){
|
||||
Add($"\"{Helpers.AddUncPrefixIfNeeded(path)}\"");
|
||||
}
|
||||
|
||||
private protected void AddRange(IEnumerable<string> args){
|
||||
Args.AddRange(args);
|
||||
}
|
||||
}
|
||||
252
CRD/Utils/Muxing/Commands/FFmpegCommandBuilder.cs
Normal file
252
CRD/Utils/Muxing/Commands/FFmpegCommandBuilder.cs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Commands;
|
||||
|
||||
public class FFmpegCommandBuilder : CommandBuilder{
|
||||
private readonly List<string> metaData = new();
|
||||
|
||||
private int index;
|
||||
private int audioIndex;
|
||||
private bool hasVideo;
|
||||
|
||||
public FFmpegCommandBuilder(MergerOptions options) : base(options){
|
||||
}
|
||||
|
||||
public override string Build(){
|
||||
AddLogLevel();
|
||||
|
||||
if (!Options.mp3)
|
||||
BuildMux();
|
||||
else
|
||||
BuildMp3();
|
||||
|
||||
return string.Join(" ", Args);
|
||||
}
|
||||
|
||||
private void AddLogLevel(){
|
||||
Add("-loglevel warning");
|
||||
}
|
||||
|
||||
private void BuildMux(){
|
||||
AddVideoInputs();
|
||||
AddAudioInputs();
|
||||
AddChapterInput();
|
||||
AddSubtitleInputs();
|
||||
|
||||
AddRange(metaData);
|
||||
|
||||
AddCodecs();
|
||||
AddSubtitleMetadata();
|
||||
AddGlobalMetadata();
|
||||
AddCustomOptions();
|
||||
AddOutput();
|
||||
}
|
||||
|
||||
private void AddVideoInputs(){
|
||||
foreach (var vid in Options.OnlyVid){
|
||||
if (!hasVideo || Options.KeepAllVideos){
|
||||
Add($"-i \"{vid.Path}\"");
|
||||
|
||||
metaData.Add($"-map {index}:v");
|
||||
metaData.Add($"-metadata:s:v:{index} title=\"{vid.Language.Name}\"");
|
||||
|
||||
hasVideo = true;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAudioInputs(){
|
||||
foreach (var aud in Options.OnlyAudio){
|
||||
if (aud.Delay is{ } delay && delay != 0){
|
||||
double offset = delay / 1000.0;
|
||||
Add($"-itsoffset {offset.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
Add($"-i \"{aud.Path}\"");
|
||||
|
||||
metaData.Add($"-map {index}:a");
|
||||
metaData.Add($"-metadata:s:a:{audioIndex} language={aud.Language.Code}");
|
||||
|
||||
AddAudioDisposition(aud);
|
||||
|
||||
index++;
|
||||
audioIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAudioDisposition(MergerInput aud){
|
||||
if (Options.Defaults.Audio?.Code == aud.Language.Code &&
|
||||
Options.Defaults.Audio != Languages.DEFAULT_lang){
|
||||
metaData.Add($"-disposition:a:{audioIndex} default");
|
||||
} else{
|
||||
metaData.Add($"-disposition:a:{audioIndex} 0");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddChapterInput(){
|
||||
if (Options.Chapters is{ Count: > 0 }){
|
||||
ConvertChapterFileForFFMPEG(Options.Chapters[0].Path);
|
||||
|
||||
Add($"-i \"{Options.Chapters[0].Path}\"");
|
||||
|
||||
metaData.Add($"-map_metadata {index}");
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSubtitleInputs(){
|
||||
if (Options.SkipSubMux)
|
||||
return;
|
||||
|
||||
bool hasSignsSub =
|
||||
Options.Subtitles.Any(s =>
|
||||
s.Signs && Options.Defaults.Sub?.Code == s.Language.Code);
|
||||
|
||||
foreach (var sub in Options.Subtitles.Select((value, i) => new{ value, i })){
|
||||
AddSubtitle(sub.value, sub.i, hasSignsSub);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSubtitle(SubtitleInput sub, int subIndex, bool hasSignsSub){
|
||||
if (sub.Delay is{ } delay && delay != 0){
|
||||
double offset = delay / 1000.0;
|
||||
Add($"-itsoffset {offset.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
Add($"-i \"{sub.File}\"");
|
||||
|
||||
metaData.Add($"-map {index}:s");
|
||||
|
||||
if (Options.Defaults.Sub?.Code == sub.Language.Code &&
|
||||
(Options.DefaultSubSigns == sub.Signs || Options.DefaultSubSigns && !hasSignsSub) &&
|
||||
!sub.ClosedCaption){
|
||||
metaData.Add($"-disposition:s:{subIndex} default");
|
||||
} else{
|
||||
metaData.Add($"-disposition:s:{subIndex} 0");
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
private void AddCodecs(){
|
||||
Add("-c:v copy");
|
||||
Add("-c:a copy");
|
||||
|
||||
Add(
|
||||
Options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)
|
||||
? "-c:s mov_text"
|
||||
: "-c:s ass"
|
||||
);
|
||||
}
|
||||
|
||||
private void AddSubtitleMetadata(){
|
||||
if (Options.SkipSubMux)
|
||||
return;
|
||||
|
||||
AddRange(
|
||||
Options.Subtitles.Select((sub, subIndex) =>
|
||||
$"-metadata:s:s:{subIndex} title=\"{sub.Language.Language ?? sub.Language.Name}" +
|
||||
$"{(sub.ClosedCaption ? $" {Options.CcTag}" : "")}" +
|
||||
$"{(sub.Signs ? " Signs" : "")}\" " +
|
||||
$"-metadata:s:s:{subIndex} language={sub.Language.Code}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private void AddGlobalMetadata(){
|
||||
if (!string.IsNullOrEmpty(Options.VideoTitle))
|
||||
Add($"-metadata title=\"{Options.VideoTitle}\"");
|
||||
|
||||
if (Options.Description is{ Count: > 0 }){
|
||||
XmlDocument doc = new();
|
||||
doc.Load(Options.Description[0].Path);
|
||||
|
||||
XmlNode? node =
|
||||
doc.SelectSingleNode("//Tag/Simple[Name='DESCRIPTION']/String");
|
||||
|
||||
string description =
|
||||
node?.InnerText
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace("\"", "\\\"")
|
||||
?? string.Empty;
|
||||
|
||||
Add($"-metadata comment=\"{description}\"");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCustomOptions(){
|
||||
if (Options.Options.Ffmpeg?.Count > 0)
|
||||
AddRange(Options.Options.Ffmpeg);
|
||||
}
|
||||
|
||||
private void AddOutput(){
|
||||
Add($"\"{Options.Output}\"");
|
||||
}
|
||||
|
||||
private void BuildMp3(){
|
||||
if (Options.OnlyAudio.Count > 1)
|
||||
Console.Error.WriteLine(
|
||||
"Multiple audio files detected. Only one audio file can be converted to MP3 at a time."
|
||||
);
|
||||
|
||||
var audio = Options.OnlyAudio.First();
|
||||
|
||||
Add($"-i \"{audio.Path}\"");
|
||||
|
||||
Add("-c:a libmp3lame" +
|
||||
(audio.Bitrate > 0 ? $" -b:a {audio.Bitrate}k" : ""));
|
||||
|
||||
Add($"\"{Options.Output}\"");
|
||||
}
|
||||
|
||||
|
||||
private void ConvertChapterFileForFFMPEG(string chapterFilePath){
|
||||
var chapterLines = File.ReadAllLines(chapterFilePath);
|
||||
var ffmpegChapterLines = new List<string>{ ";FFMETADATA1" };
|
||||
var chapters = new List<(double StartTime, string Title)>();
|
||||
|
||||
for (int i = 0; i < chapterLines.Length; i += 2){
|
||||
var timeLine = chapterLines[i];
|
||||
var nameLine = chapterLines[i + 1];
|
||||
|
||||
var timeParts = timeLine.Split('=');
|
||||
var nameParts = nameLine.Split('=');
|
||||
|
||||
if (timeParts.Length == 2 && nameParts.Length == 2){
|
||||
var startTime = TimeSpan.Parse(timeParts[1]).TotalMilliseconds;
|
||||
var title = nameParts[1];
|
||||
chapters.Add((startTime, title));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chapters by start time
|
||||
chapters = chapters.OrderBy(c => c.StartTime).ToList();
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++){
|
||||
var startTime = chapters[i].StartTime;
|
||||
var title = chapters[i].Title;
|
||||
var endTime = (i + 1 < chapters.Count) ? chapters[i + 1].StartTime : startTime + 10000; // Add 10 seconds to the last chapter end time
|
||||
|
||||
if (endTime < startTime){
|
||||
endTime = startTime + 10000; // Correct end time if it is before start time
|
||||
}
|
||||
|
||||
ffmpegChapterLines.Add("[CHAPTER]");
|
||||
ffmpegChapterLines.Add("TIMEBASE=1/1000");
|
||||
ffmpegChapterLines.Add($"START={startTime}");
|
||||
ffmpegChapterLines.Add($"END={endTime}");
|
||||
ffmpegChapterLines.Add($"title={title}");
|
||||
}
|
||||
|
||||
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
|
||||
}
|
||||
}
|
||||
244
CRD/Utils/Muxing/Commands/MkvMergeCommandBuilder.cs
Normal file
244
CRD/Utils/Muxing/Commands/MkvMergeCommandBuilder.cs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Commands;
|
||||
|
||||
public class MkvMergeCommandBuilder(MergerOptions options) : CommandBuilder(options){
|
||||
private bool hasVideo;
|
||||
|
||||
public override string Build(){
|
||||
AddOutput();
|
||||
AddCustomOptions();
|
||||
AddVideoAudio();
|
||||
AddSubtitles();
|
||||
AddFonts();
|
||||
AddChapters();
|
||||
AddTitle();
|
||||
AddDescription();
|
||||
AddCover();
|
||||
|
||||
return string.Join(" ", Args);
|
||||
}
|
||||
|
||||
|
||||
private void AddOutput(){
|
||||
Add($"-o \"{Helpers.AddUncPrefixIfNeeded(Options.Output)}\"");
|
||||
}
|
||||
|
||||
private void AddCustomOptions(){
|
||||
if (Options.Options.Mkvmerge != null)
|
||||
AddRange(Options.Options.Mkvmerge);
|
||||
}
|
||||
|
||||
private void AddVideoAudio(){
|
||||
if (Options.VideoAndAudio.Count > 0)
|
||||
AddCombinedVideoAudio();
|
||||
else{
|
||||
AddVideoOnly();
|
||||
AddAudioOnly();
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCombinedVideoAudio(){
|
||||
var rank = Options.DubLangList
|
||||
.Select((v, i) => new{ v, i })
|
||||
.ToDictionary(x => x.v, x => x.i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var sorted = Options.VideoAndAudio
|
||||
.OrderBy(m => {
|
||||
var key = m.Language?.CrLocale ?? string.Empty;
|
||||
return rank.TryGetValue(key, out var r) ? r : int.MaxValue;
|
||||
})
|
||||
.ThenBy(m => m.IsAudioRoleDescription)
|
||||
.ToList();
|
||||
|
||||
foreach (var track in sorted){
|
||||
AddCombinedTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCombinedTrack(MergerInput track){
|
||||
var videoTrackNum = "0";
|
||||
var audioTrackNum = "1";
|
||||
|
||||
if (!hasVideo || Options.KeepAllVideos){
|
||||
Add($"--video-tracks {videoTrackNum}");
|
||||
Add($"--audio-tracks {audioTrackNum}");
|
||||
|
||||
AddTrackMetadata(videoTrackNum, track.Language);
|
||||
|
||||
hasVideo = true;
|
||||
} else{
|
||||
Add("--no-video");
|
||||
Add($"--audio-tracks {audioTrackNum}");
|
||||
}
|
||||
|
||||
AddAudioMetadata(audioTrackNum, track);
|
||||
|
||||
AddInput(track.Path);
|
||||
}
|
||||
|
||||
private void AddVideoOnly(){
|
||||
foreach (var vid in Options.OnlyVid){
|
||||
if (!hasVideo || Options.KeepAllVideos){
|
||||
Add("--video-tracks 0");
|
||||
Add("--no-audio");
|
||||
|
||||
AddTrackMetadata("0", vid.Language);
|
||||
|
||||
hasVideo = true;
|
||||
|
||||
AddInput(vid.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAudioOnly(){
|
||||
var sorted = Options.OnlyAudio
|
||||
.OrderBy(a => {
|
||||
var index = Options.DubLangList.IndexOf(a.Language.CrLocale);
|
||||
return index != -1 ? index : int.MaxValue;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
foreach (var aud in sorted){
|
||||
Add("--audio-tracks 0");
|
||||
Add("--no-video");
|
||||
|
||||
AddAudioMetadata("0", aud);
|
||||
|
||||
if (aud.Delay is{ } delay && delay != 0)
|
||||
Add($"--sync 0:{delay}");
|
||||
|
||||
AddInput(aud.Path);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddTrackMetadata(string trackNum, LanguageItem lang){
|
||||
Add($"--track-name {trackNum}:\"{lang.Name}\"");
|
||||
Add($"--language {trackNum}:{lang.Code}");
|
||||
}
|
||||
|
||||
private void AddAudioMetadata(string trackNum, MergerInput track){
|
||||
var name = track.Language.Name +
|
||||
(track.IsAudioRoleDescription ? " [AD]" : "");
|
||||
|
||||
Add($"--track-name {trackNum}:\"{name}\"");
|
||||
Add($"--language {trackNum}:{track.Language.Code}");
|
||||
|
||||
AddDefaultAudio(trackNum, track.Language);
|
||||
}
|
||||
|
||||
private void AddDefaultAudio(string trackNum, LanguageItem lang){
|
||||
if (Options.Defaults.Audio?.Code == lang.Code &&
|
||||
Options.Defaults.Audio != Languages.DEFAULT_lang){
|
||||
Add($"--default-track {trackNum}");
|
||||
} else{
|
||||
Add($"--default-track {trackNum}:0");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSubtitles(){
|
||||
if (Options.Subtitles.Count == 0 || Options.SkipSubMux){
|
||||
Add("--no-subtitles");
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasSignsSub =
|
||||
Options.Subtitles.Any(s => s.Signs &&
|
||||
Options.Defaults.Sub?.Code == s.Language.Code);
|
||||
|
||||
var sorted = Options.Subtitles
|
||||
.OrderBy(s => Options.SubLangList.IndexOf(s.Language.CrLocale) != -1
|
||||
? Options.SubLangList.IndexOf(s.Language.CrLocale)
|
||||
: int.MaxValue)
|
||||
.ThenBy(s => s.ClosedCaption ? 2 : s.Signs ? 1 : 0)
|
||||
.ToList();
|
||||
|
||||
foreach (var sub in sorted)
|
||||
AddSubtitle(sub, hasSignsSub);
|
||||
}
|
||||
|
||||
private void AddSubtitle(SubtitleInput subObj, bool hasSignsSub){
|
||||
bool isForced = false;
|
||||
|
||||
AddIf(subObj.Delay.HasValue, $"--sync 0:{subObj.Delay}");
|
||||
|
||||
string extra = subObj.ClosedCaption ? $" {Options.CcTag}" : "";
|
||||
extra += subObj.Signs ? " Signs" : "";
|
||||
|
||||
string name = (subObj.Language.Language ?? subObj.Language.Name) + extra;
|
||||
|
||||
Add($"--track-name 0:\"{name}\"");
|
||||
Add($"--language 0:\"{subObj.Language.Code}\"");
|
||||
|
||||
AddSubtitleDefaults(subObj, hasSignsSub, ref isForced);
|
||||
|
||||
if (subObj.ClosedCaption && Options.CcSubsMuxingFlag)
|
||||
Add("--hearing-impaired-flag 0:yes");
|
||||
|
||||
if (subObj.Signs && Options.SignsSubsAsForced && !isForced)
|
||||
Add("--forced-track 0:yes");
|
||||
|
||||
AddInput(subObj.File);
|
||||
}
|
||||
|
||||
private void AddSubtitleDefaults(SubtitleInput subObj, bool hasSignsSub, ref bool isForced){
|
||||
if (Options.Defaults.Sub != null && Options.Defaults.Sub != Languages.DEFAULT_lang){
|
||||
if (Options.Defaults.Sub.Code == subObj.Language.Code &&
|
||||
(Options.DefaultSubSigns == subObj.Signs || Options.DefaultSubSigns && !hasSignsSub) &&
|
||||
subObj.ClosedCaption == false){
|
||||
Add("--default-track 0");
|
||||
|
||||
if (Options.DefaultSubForcedDisplay){
|
||||
Add("--forced-track 0:yes");
|
||||
isForced = true;
|
||||
}
|
||||
} else{
|
||||
Add("--default-track 0:0");
|
||||
}
|
||||
} else{
|
||||
Add("--default-track 0:0");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddFonts(){
|
||||
if (Options.Fonts is not{ Count: > 0 }){
|
||||
Add("--no-attachments");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var font in Options.Fonts){
|
||||
Add($"--attachment-name \"{font.Name}\"");
|
||||
Add($"--attachment-mime-type \"{font.Mime}\"");
|
||||
Add($"--attach-file \"{Helpers.AddUncPrefixIfNeeded(font.Path)}\"");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddChapters(){
|
||||
if (Options.Chapters is{ Count: > 0 })
|
||||
Add($"--chapters \"{Helpers.AddUncPrefixIfNeeded(Options.Chapters[0].Path)}\"");
|
||||
}
|
||||
|
||||
private void AddTitle(){
|
||||
if (!string.IsNullOrEmpty(Options.VideoTitle))
|
||||
Add($"--title \"{Options.VideoTitle}\"");
|
||||
}
|
||||
|
||||
private void AddDescription(){
|
||||
if (Options.Description is{ Count: > 0 })
|
||||
Add($"--global-tags \"{Helpers.AddUncPrefixIfNeeded(Options.Description[0].Path)}\"");
|
||||
}
|
||||
|
||||
private void AddCover(){
|
||||
var cover = Options.Cover.FirstOrDefault();
|
||||
|
||||
if (cover?.Path != null){
|
||||
Add($"--attach-file \"{cover.Path}\"");
|
||||
Add("--attachment-mime-type image/png");
|
||||
Add("--attachment-name cover.png");
|
||||
}
|
||||
}
|
||||
}
|
||||
516
CRD/Utils/Muxing/Fonts/FontsManager.cs
Normal file
516
CRD/Utils/Muxing/Fonts/FontsManager.cs
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
using System;
|
||||
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.Muxing.Structs;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
using SixLabors.Fonts;
|
||||
|
||||
namespace CRD.Utils.Muxing.Fonts;
|
||||
|
||||
public class FontsManager{
|
||||
#region Singelton
|
||||
|
||||
private static readonly Lock Padlock = new Lock();
|
||||
|
||||
public static FontsManager Instance{
|
||||
get{
|
||||
if (field == null){
|
||||
lock (Padlock){
|
||||
if (field == null){
|
||||
field = new FontsManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
|
||||
{ "Adobe Arabic", "AdobeArabic-Bold.otf" },
|
||||
{ "Andale Mono", "andalemo.ttf" },
|
||||
{ "Arial", "arial.ttf" },
|
||||
{ "Arial Black", "ariblk.ttf" },
|
||||
{ "Arial Bold", "arialbd.ttf" },
|
||||
{ "Arial Bold Italic", "arialbi.ttf" },
|
||||
{ "Arial Italic", "ariali.ttf" },
|
||||
{ "Arial Unicode MS", "arialuni.ttf" },
|
||||
{ "Comic Sans MS", "comic.ttf" },
|
||||
{ "Comic Sans MS Bold", "comicbd.ttf" },
|
||||
{ "Courier New", "cour.ttf" },
|
||||
{ "Courier New Bold", "courbd.ttf" },
|
||||
{ "Courier New Bold Italic", "courbi.ttf" },
|
||||
{ "Courier New Italic", "couri.ttf" },
|
||||
{ "DejaVu LGC Sans Mono", "DejaVuLGCSansMono.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Bold", "DejaVuLGCSansMono-Bold.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Bold Oblique", "DejaVuLGCSansMono-BoldOblique.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Oblique", "DejaVuLGCSansMono-Oblique.ttf" },
|
||||
{ "DejaVu Sans", "DejaVuSans.ttf" },
|
||||
{ "DejaVu Sans Bold", "DejaVuSans-Bold.ttf" },
|
||||
{ "DejaVu Sans Bold Oblique", "DejaVuSans-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Condensed", "DejaVuSansCondensed.ttf" },
|
||||
{ "DejaVu Sans Condensed Bold", "DejaVuSansCondensed-Bold.ttf" },
|
||||
{ "DejaVu Sans Condensed Bold Oblique", "DejaVuSansCondensed-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Condensed Oblique", "DejaVuSansCondensed-Oblique.ttf" },
|
||||
{ "DejaVu Sans ExtraLight", "DejaVuSans-ExtraLight.ttf" },
|
||||
{ "DejaVu Sans Mono", "DejaVuSansMono.ttf" },
|
||||
{ "DejaVu Sans Mono Bold", "DejaVuSansMono-Bold.ttf" },
|
||||
{ "DejaVu Sans Mono Bold Oblique", "DejaVuSansMono-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Mono Oblique", "DejaVuSansMono-Oblique.ttf" },
|
||||
{ "DejaVu Sans Oblique", "DejaVuSans-Oblique.ttf" },
|
||||
{ "Gautami", "gautami.ttf" },
|
||||
{ "Georgia", "georgia.ttf" },
|
||||
{ "Georgia Bold", "georgiab.ttf" },
|
||||
{ "Georgia Bold Italic", "georgiaz.ttf" },
|
||||
{ "Georgia Italic", "georgiai.ttf" },
|
||||
{ "Impact", "impact.ttf" },
|
||||
{ "Mangal", "MANGAL.woff2" },
|
||||
{ "Meera Inimai", "MeeraInimai-Regular.ttf" },
|
||||
{ "Noto Sans Tamil", "NotoSansTamilVariable.ttf" },
|
||||
{ "Noto Sans Telugu", "NotoSansTeluguVariable.ttf" },
|
||||
{ "Noto Sans Thai", "NotoSansThai.ttf" },
|
||||
{ "Rubik", "Rubik-Regular.ttf" },
|
||||
{ "Rubik Black", "Rubik-Black.ttf" },
|
||||
{ "Rubik Black Italic", "Rubik-BlackItalic.ttf" },
|
||||
{ "Rubik Bold", "Rubik-Bold.ttf" },
|
||||
{ "Rubik Bold Italic", "Rubik-BoldItalic.ttf" },
|
||||
{ "Rubik Italic", "Rubik-Italic.ttf" },
|
||||
{ "Rubik Light", "Rubik-Light.ttf" },
|
||||
{ "Rubik Light Italic", "Rubik-LightItalic.ttf" },
|
||||
{ "Rubik Medium", "Rubik-Medium.ttf" },
|
||||
{ "Rubik Medium Italic", "Rubik-MediumItalic.ttf" },
|
||||
{ "Tahoma", "tahoma.ttf" },
|
||||
{ "Times New Roman", "times.ttf" },
|
||||
{ "Times New Roman Bold", "timesbd.ttf" },
|
||||
{ "Times New Roman Bold Italic", "timesbi.ttf" },
|
||||
{ "Times New Roman Italic", "timesi.ttf" },
|
||||
{ "Trebuchet MS", "trebuc.ttf" },
|
||||
{ "Trebuchet MS Bold", "trebucbd.ttf" },
|
||||
{ "Trebuchet MS Bold Italic", "trebucbi.ttf" },
|
||||
{ "Trebuchet MS Italic", "trebucit.ttf" },
|
||||
{ "Verdana", "verdana.ttf" },
|
||||
{ "Verdana Bold", "verdanab.ttf" },
|
||||
{ "Verdana Bold Italic", "verdanaz.ttf" },
|
||||
{ "Verdana Italic", "verdanai.ttf" },
|
||||
{ "Vrinda", "vrinda.ttf" },
|
||||
{ "Vrinda Bold", "vrindab.ttf" },
|
||||
{ "Webdings", "webdings.ttf" }
|
||||
};
|
||||
|
||||
private readonly FontIndex index = new();
|
||||
private int _fontSourceNoticePrinted;
|
||||
|
||||
private void EnsureIndex(string fontsDir){
|
||||
index.Rebuild(GetFontSearchDirectories(fontsDir));
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
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){
|
||||
if (string.IsNullOrWhiteSpace(ass))
|
||||
return new List<string>();
|
||||
|
||||
ass = ass.Replace("\r", "");
|
||||
var lines = ass.Split('\n');
|
||||
|
||||
var fonts = new List<string>();
|
||||
|
||||
foreach (var line in lines){
|
||||
if (line.StartsWith("Style: ", StringComparison.OrdinalIgnoreCase)){
|
||||
var parts = line.Substring(7).Split(',');
|
||||
if (parts.Length > 1){
|
||||
var fontName = parts[1].Trim();
|
||||
fonts.Add(NormalizeFontKey(fontName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkTypesettingFonts){
|
||||
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
|
||||
foreach (Match match in fontMatches){
|
||||
if (match.Groups.Count > 1){
|
||||
var fontName = match.Groups[1].Value.Trim();
|
||||
|
||||
if (Regex.IsMatch(fontName, @"^\d+$"))
|
||||
continue;
|
||||
|
||||
fonts.Add(NormalizeFontKey(fontName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fonts
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList, bool keepUnknown = true){
|
||||
Dictionary<string, string> filteredDictionary = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string key in keysList){
|
||||
var k = NormalizeFontKey(key);
|
||||
|
||||
if (Fonts.TryGetValue(k, out var fontFile)){
|
||||
filteredDictionary[k] = fontFile;
|
||||
} else if (keepUnknown){
|
||||
filteredDictionary[k] = k;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDictionary;
|
||||
}
|
||||
|
||||
public static string GetFontMimeType(string fontFileOrPath){
|
||||
var ext = Path.GetExtension(fontFileOrPath);
|
||||
if (ext.Equals(".otf", StringComparison.OrdinalIgnoreCase))
|
||||
return "application/vnd.ms-opentype";
|
||||
if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase))
|
||||
return "application/x-truetype-font";
|
||||
if (ext.Equals(".ttc", StringComparison.OrdinalIgnoreCase) || ext.Equals(".otc", StringComparison.OrdinalIgnoreCase))
|
||||
return "application/x-truetype-font";
|
||||
if (ext.Equals(".woff", StringComparison.OrdinalIgnoreCase))
|
||||
return "font/woff";
|
||||
if (ext.Equals(".woff2", StringComparison.OrdinalIgnoreCase))
|
||||
return "font/woff2";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
|
||||
EnsureIndex(fontsDir);
|
||||
|
||||
var required = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var subsLocales = new List<string>();
|
||||
var fontsList = new List<ParsedFont>();
|
||||
var missing = new List<string>();
|
||||
|
||||
foreach (var s in subs){
|
||||
subsLocales.Add(s.Language.Locale);
|
||||
|
||||
foreach (var kv in s.Fonts)
|
||||
required.Add(NormalizeFontKey(kv.Key));
|
||||
}
|
||||
|
||||
if (subsLocales.Count > 0)
|
||||
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsLocales), subsLocales.Count);
|
||||
|
||||
if (required.Count > 0)
|
||||
Console.WriteLine("Required fonts: {0} (Total: {1})", string.Join(", ", required), required.Count);
|
||||
|
||||
foreach (var requested in required){
|
||||
if (TryResolveFontPath(requested, fontsDir, out var resolvedPath, out var exact)){
|
||||
if (!File.Exists(resolvedPath) || new FileInfo(resolvedPath).Length == 0){
|
||||
missing.Add(requested);
|
||||
continue;
|
||||
}
|
||||
|
||||
var attachName = MakeUniqueAttachmentName(resolvedPath, fontsList);
|
||||
|
||||
fontsList.Add(new ParsedFont{
|
||||
Name = attachName,
|
||||
Path = resolvedPath,
|
||||
Mime = GetFontMimeType(resolvedPath)
|
||||
});
|
||||
|
||||
if (!exact) Console.WriteLine($"Soft-resolved '{requested}' -> '{Path.GetFileName(resolvedPath)}'");
|
||||
} else{
|
||||
missing.Add(requested);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.Count > 0)
|
||||
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}\n\nAdd the missing font files to:\n{CfgManager.PathFONTS_DIR}");
|
||||
|
||||
return fontsList;
|
||||
}
|
||||
|
||||
private bool TryResolveFontPath(string requestedName, string fontsDir, out string resolvedPath, out bool isExactMatch){
|
||||
resolvedPath = string.Empty;
|
||||
isExactMatch = true;
|
||||
|
||||
var req = NormalizeFontKey(requestedName);
|
||||
|
||||
if (index.TryResolve(req, out resolvedPath))
|
||||
return true;
|
||||
|
||||
if (Fonts.TryGetValue(req, out var crFile)){
|
||||
var p = FindKnownFontFile(crFile, fontsDir);
|
||||
if (!string.IsNullOrEmpty(p)){
|
||||
resolvedPath = p;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var family = StripStyleSuffix(req);
|
||||
if (!family.Equals(req, StringComparison.OrdinalIgnoreCase)){
|
||||
isExactMatch = false;
|
||||
|
||||
if (index.TryResolve(family, out resolvedPath))
|
||||
return true;
|
||||
|
||||
if (Fonts.TryGetValue(family, out var crFamilyFile)){
|
||||
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 parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var styleWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase){
|
||||
"Bold", "Italic", "Oblique", "Regular", "Black",
|
||||
"Light", "Medium", "Semi", "Condensed"
|
||||
};
|
||||
|
||||
var filtered = parts.Where(p => !styleWords.Contains(p)).ToList();
|
||||
|
||||
return filtered.Count > 0 ? string.Join(" ", filtered) : name;
|
||||
}
|
||||
|
||||
private static string NormalizeFontKey(string s){
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
return string.Empty;
|
||||
|
||||
s = s.Trim().Trim('"');
|
||||
|
||||
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('-', ' ');
|
||||
|
||||
// remove MT suffix (ArialMT → Arial)
|
||||
s = Regex.Replace(s, @"MT$", "", 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);
|
||||
|
||||
if (existing.All(e => !baseName.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return baseName;
|
||||
|
||||
var hash = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(path)))
|
||||
.Substring(0, 8)
|
||||
.ToLowerInvariant();
|
||||
|
||||
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(IEnumerable<string> fontDirs){
|
||||
map.Clear();
|
||||
foreach (var fontsDir in fontDirs){
|
||||
if (!Directory.Exists(fontsDir))
|
||||
continue;
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolve(string fontName, out string path){
|
||||
path = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(fontName)) return false;
|
||||
|
||||
var key = NormalizeFontKey(fontName);
|
||||
|
||||
if (map.TryGetValue(fontName, out var c1)){
|
||||
path = c1.Path;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (map.TryGetValue(key, out var c2)){
|
||||
path = c2.Path;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Add(string alias, string path){
|
||||
if (string.IsNullOrWhiteSpace(alias)) return;
|
||||
|
||||
var a1 = alias.Trim();
|
||||
var a2 = NormalizeFontKey(a1);
|
||||
|
||||
Upsert(a1, path);
|
||||
Upsert(a2, path);
|
||||
}
|
||||
|
||||
private void Upsert(string key, string path){
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
var cand = new Candidate(path, GetScore(path));
|
||||
if (map.TryGetValue(key, out var existing)){
|
||||
if (cand.Score > existing.Score)
|
||||
map[key] = cand;
|
||||
} else{
|
||||
map[key] = cand;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetScore(string path){
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch{
|
||||
".ttf" => 100,
|
||||
".otf" => 95,
|
||||
".ttc" => 90,
|
||||
".otc" => 85,
|
||||
".woff" => 40,
|
||||
".woff2" => 35,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<FontDescription> LoadDescriptions(string fontPath){
|
||||
var ext = Path.GetExtension(fontPath).ToLowerInvariant();
|
||||
if (ext is ".ttc" or ".otc")
|
||||
return FontDescription.LoadFontCollectionDescriptions(fontPath);
|
||||
|
||||
return new[]{ FontDescription.LoadDescription(fontPath) };
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildAliases(FontDescription d){
|
||||
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;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(family) &&
|
||||
!string.IsNullOrWhiteSpace(sub) &&
|
||||
!sub.Equals("Regular", StringComparison.OrdinalIgnoreCase)){
|
||||
yield return $"{family} {sub}";
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Candidate(string Path, int Score);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubtitleFonts{
|
||||
public LanguageItem Language{ get; set; } = new();
|
||||
public Dictionary<string, string> Fonts{ get; set; } = new();
|
||||
}
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
|
||||
namespace CRD.Utils.Muxing;
|
||||
|
||||
public class FontsManager{
|
||||
#region Singelton
|
||||
|
||||
private static FontsManager? instance;
|
||||
private static readonly object padlock = new object();
|
||||
|
||||
public static FontsManager Instance{
|
||||
get{
|
||||
if (instance == null){
|
||||
lock (padlock){
|
||||
if (instance == null){
|
||||
instance = new FontsManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
|
||||
{ "Adobe Arabic", "AdobeArabic-Bold.otf" },
|
||||
{ "Andale Mono", "andalemo.ttf" },
|
||||
{ "Arial", "arial.ttf" },
|
||||
{ "Arial Black", "ariblk.ttf" },
|
||||
{ "Arial Bold", "arialbd.ttf" },
|
||||
{ "Arial Bold Italic", "arialbi.ttf" },
|
||||
{ "Arial Italic", "ariali.ttf" },
|
||||
{ "Arial Unicode MS", "arialuni.ttf" },
|
||||
{ "Comic Sans MS", "comic.ttf" },
|
||||
{ "Comic Sans MS Bold", "comicbd.ttf" },
|
||||
{ "Courier New", "cour.ttf" },
|
||||
{ "Courier New Bold", "courbd.ttf" },
|
||||
{ "Courier New Bold Italic", "courbi.ttf" },
|
||||
{ "Courier New Italic", "couri.ttf" },
|
||||
{ "DejaVu LGC Sans Mono", "DejaVuLGCSansMono.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Bold", "DejaVuLGCSansMono-Bold.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Bold Oblique", "DejaVuLGCSansMono-BoldOblique.ttf" },
|
||||
{ "DejaVu LGC Sans Mono Oblique", "DejaVuLGCSansMono-Oblique.ttf" },
|
||||
{ "DejaVu Sans", "DejaVuSans.ttf" },
|
||||
{ "DejaVu Sans Bold", "DejaVuSans-Bold.ttf" },
|
||||
{ "DejaVu Sans Bold Oblique", "DejaVuSans-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Condensed", "DejaVuSansCondensed.ttf" },
|
||||
{ "DejaVu Sans Condensed Bold", "DejaVuSansCondensed-Bold.ttf" },
|
||||
{ "DejaVu Sans Condensed Bold Oblique", "DejaVuSansCondensed-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Condensed Oblique", "DejaVuSansCondensed-Oblique.ttf" },
|
||||
{ "DejaVu Sans ExtraLight", "DejaVuSans-ExtraLight.ttf" },
|
||||
{ "DejaVu Sans Mono", "DejaVuSansMono.ttf" },
|
||||
{ "DejaVu Sans Mono Bold", "DejaVuSansMono-Bold.ttf" },
|
||||
{ "DejaVu Sans Mono Bold Oblique", "DejaVuSansMono-BoldOblique.ttf" },
|
||||
{ "DejaVu Sans Mono Oblique", "DejaVuSansMono-Oblique.ttf" },
|
||||
{ "DejaVu Sans Oblique", "DejaVuSans-Oblique.ttf" },
|
||||
{ "Gautami", "gautami.ttf" },
|
||||
{ "Georgia", "georgia.ttf" },
|
||||
{ "Georgia Bold", "georgiab.ttf" },
|
||||
{ "Georgia Bold Italic", "georgiaz.ttf" },
|
||||
{ "Georgia Italic", "georgiai.ttf" },
|
||||
{ "Impact", "impact.ttf" },
|
||||
{ "Mangal", "MANGAL.woff2" },
|
||||
{ "Meera Inimai", "MeeraInimai-Regular.ttf" },
|
||||
{ "Noto Sans Tamil", "NotoSansTamilVariable.ttf" },
|
||||
{ "Noto Sans Telugu", "NotoSansTeluguVariable.ttf" },
|
||||
{ "Noto Sans Thai", "NotoSansThai.ttf" },
|
||||
{ "Rubik", "Rubik-Regular.ttf" },
|
||||
{ "Rubik Black", "Rubik-Black.ttf" },
|
||||
{ "Rubik Black Italic", "Rubik-BlackItalic.ttf" },
|
||||
{ "Rubik Bold", "Rubik-Bold.ttf" },
|
||||
{ "Rubik Bold Italic", "Rubik-BoldItalic.ttf" },
|
||||
{ "Rubik Italic", "Rubik-Italic.ttf" },
|
||||
{ "Rubik Light", "Rubik-Light.ttf" },
|
||||
{ "Rubik Light Italic", "Rubik-LightItalic.ttf" },
|
||||
{ "Rubik Medium", "Rubik-Medium.ttf" },
|
||||
{ "Rubik Medium Italic", "Rubik-MediumItalic.ttf" },
|
||||
{ "Tahoma", "tahoma.ttf" },
|
||||
{ "Times New Roman", "times.ttf" },
|
||||
{ "Times New Roman Bold", "timesbd.ttf" },
|
||||
{ "Times New Roman Bold Italic", "timesbi.ttf" },
|
||||
{ "Times New Roman Italic", "timesi.ttf" },
|
||||
{ "Trebuchet MS", "trebuc.ttf" },
|
||||
{ "Trebuchet MS Bold", "trebucbd.ttf" },
|
||||
{ "Trebuchet MS Bold Italic", "trebucbi.ttf" },
|
||||
{ "Trebuchet MS Italic", "trebucit.ttf" },
|
||||
{ "Verdana", "verdana.ttf" },
|
||||
{ "Verdana Bold", "verdanab.ttf" },
|
||||
{ "Verdana Bold Italic", "verdanaz.ttf" },
|
||||
{ "Verdana Italic", "verdanai.ttf" },
|
||||
{ "Vrinda", "vrinda.ttf" },
|
||||
{ "Vrinda Bold", "vrindab.ttf" },
|
||||
{ "Webdings", "webdings.ttf" }
|
||||
};
|
||||
|
||||
|
||||
public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
|
||||
|
||||
|
||||
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){
|
||||
// Console.WriteLine($"{font} already downloaded!");
|
||||
} else{
|
||||
var fontFolder = Path.GetDirectoryName(fontLoc);
|
||||
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
|
||||
File.Delete(fontLoc);
|
||||
}
|
||||
|
||||
try{
|
||||
if (!Directory.Exists(fontFolder)){
|
||||
Directory.CreateDirectory(fontFolder);
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.WriteLine($"Failed to create directory: {e.Message}");
|
||||
}
|
||||
|
||||
var fontUrl = root + font;
|
||||
|
||||
var httpClient = HttpClientReq.Instance.GetHttpClient();
|
||||
try{
|
||||
var response = await httpClient.GetAsync(fontUrl);
|
||||
if (response.IsSuccessStatusCode){
|
||||
var fontData = await response.Content.ReadAsByteArrayAsync();
|
||||
await File.WriteAllBytesAsync(fontLoc, fontData);
|
||||
Console.WriteLine($"Downloaded: {font}");
|
||||
} else{
|
||||
Console.Error.WriteLine($"Failed to download: {font}");
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("All required fonts downloaded!");
|
||||
}
|
||||
|
||||
|
||||
public static List<string> ExtractFontsFromAss(string ass){
|
||||
var lines = ass.Replace("\r", "").Split('\n');
|
||||
var styles = new List<string>();
|
||||
|
||||
foreach (var line in lines){
|
||||
if (line.StartsWith("Style: ")){
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length > 1)
|
||||
styles.Add(parts[1].Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
|
||||
foreach (Match match in fontMatches){
|
||||
if (match.Groups.Count > 1)
|
||||
styles.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
return styles.Distinct().ToList(); // Using Linq to remove duplicates
|
||||
}
|
||||
|
||||
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList){
|
||||
Dictionary<string, string> filteredDictionary = new Dictionary<string, string>();
|
||||
|
||||
foreach (string key in keysList){
|
||||
if (Fonts.TryGetValue(key, out var font)){
|
||||
filteredDictionary.Add(key, font);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDictionary;
|
||||
}
|
||||
|
||||
|
||||
public static string GetFontMimeType(string fontFile){
|
||||
if (Regex.IsMatch(fontFile, @"\.otf$"))
|
||||
return "application/vnd.ms-opentype";
|
||||
else if (Regex.IsMatch(fontFile, @"\.ttf$"))
|
||||
return "application/x-truetype-font";
|
||||
else
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
|
||||
Dictionary<string, string> fontsNameList = new Dictionary<string, string>();
|
||||
List<string> subsList = new List<string>();
|
||||
List<ParsedFont> fontsList = new List<ParsedFont>();
|
||||
bool isNstr = true;
|
||||
|
||||
foreach (var s in subs){
|
||||
foreach (var keyValuePair in s.Fonts){
|
||||
if (!fontsNameList.ContainsKey(keyValuePair.Key)){
|
||||
fontsNameList.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
subsList.Add(s.Language.Locale);
|
||||
}
|
||||
|
||||
if (subsList.Count > 0){
|
||||
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count);
|
||||
isNstr = false;
|
||||
}
|
||||
|
||||
if (fontsNameList.Count > 0){
|
||||
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
|
||||
}
|
||||
|
||||
List<string> missingFonts = new List<string>();
|
||||
|
||||
foreach (var f in fontsNameList){
|
||||
if (Fonts.TryGetValue(f.Key, out var fontFile)){
|
||||
string fontPath = Path.Combine(fontsDir, fontFile);
|
||||
string mime = GetFontMimeType(fontFile);
|
||||
if (File.Exists(fontPath) && new FileInfo(fontPath).Length != 0){
|
||||
fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime });
|
||||
}
|
||||
} else{
|
||||
missingFonts.Add(f.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFonts.Count > 0){
|
||||
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}");
|
||||
}
|
||||
|
||||
return fontsList;
|
||||
}
|
||||
}
|
||||
|
||||
public class SubtitleFonts{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public Dictionary<string, string> Fonts{ get; set; }
|
||||
}
|
||||
|
|
@ -1,415 +1,34 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Muxing.Commands;
|
||||
using CRD.Utils.Muxing.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing;
|
||||
|
||||
public class Merger{
|
||||
public MergerOptions options;
|
||||
public MergerOptions Options;
|
||||
|
||||
public Merger(MergerOptions options){
|
||||
this.options = options;
|
||||
Options = options;
|
||||
|
||||
if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){
|
||||
this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'");
|
||||
|
||||
if (Options.VideoTitle is{ Length: > 0 }){
|
||||
Options.VideoTitle = Options.VideoTitle.Replace("\"", "'");
|
||||
}
|
||||
}
|
||||
|
||||
public string FFmpeg(){
|
||||
List<string> args = new List<string>();
|
||||
|
||||
List<string> metaData = new List<string>();
|
||||
|
||||
var index = 0;
|
||||
var audioIndex = 0;
|
||||
var hasVideo = false;
|
||||
|
||||
args.Add("-loglevel warning");
|
||||
|
||||
if (!options.mp3){
|
||||
foreach (var vid in options.OnlyVid){
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
args.Add($"-i \"{vid.Path}\"");
|
||||
metaData.Add($"-map {index}:v");
|
||||
metaData.Add($"-metadata:s:v:{index} title=\"{(vid.Language.Name)}\"");
|
||||
hasVideo = true;
|
||||
index++;
|
||||
return new FFmpegCommandBuilder(Options).Build();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var aud in options.OnlyAudio){
|
||||
if (aud.Delay != null && aud.Delay != 0){
|
||||
double delay = aud.Delay / 1000.0 ?? 0;
|
||||
args.Add($"-itsoffset {delay.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
args.Add($"-i \"{aud.Path}\"");
|
||||
metaData.Add($"-map {index}:a");
|
||||
metaData.Add($"-metadata:s:a:{audioIndex} language={aud.Language.Code}");
|
||||
if (options.Defaults.Audio.Code == aud.Language.Code){
|
||||
metaData.Add($"-disposition:a:{audioIndex} default");
|
||||
} else{
|
||||
metaData.Add($"-disposition:a:{audioIndex} 0");
|
||||
}
|
||||
|
||||
index++;
|
||||
audioIndex++;
|
||||
}
|
||||
|
||||
if (options.Chapters != null && options.Chapters.Count > 0){
|
||||
Helpers.ConvertChapterFileForFFMPEG(options.Chapters[0].Path);
|
||||
|
||||
args.Add($"-i \"{options.Chapters[0].Path}\"");
|
||||
metaData.Add($"-map_metadata {index}");
|
||||
index++;
|
||||
}
|
||||
|
||||
if (!options.SkipSubMux){
|
||||
bool hasSignsSub = options.Subtitles.Any(sub => sub.Signs && options.Defaults.Sub.Code == sub.Language.Code);
|
||||
|
||||
foreach (var sub in options.Subtitles.Select((value, i) => new{ value, i })){
|
||||
if (sub.value.Delay != null && sub.value.Delay != 0){
|
||||
double delay = sub.value.Delay / 1000.0 ?? 0;
|
||||
args.Add($"-itsoffset {delay.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
args.Add($"-i \"{sub.value.File}\"");
|
||||
metaData.Add($"-map {index}:s");
|
||||
if (options.Defaults.Sub.Code == sub.value.Language.Code &&
|
||||
(options.DefaultSubSigns == sub.value.Signs || options.DefaultSubSigns && !hasSignsSub)
|
||||
&& sub.value.ClosedCaption == false){
|
||||
metaData.Add($"-disposition:s:{sub.i} default");
|
||||
} else{
|
||||
metaData.Add($"-disposition:s:{sub.i} 0");
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
args.AddRange(metaData);
|
||||
// args.AddRange(options.Subtitles.Select((sub, subIndex) => $"-map {subIndex + index}"));
|
||||
args.Add("-c:v copy");
|
||||
args.Add("-c:a copy");
|
||||
args.Add(options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ? "-c:s mov_text" : "-c:s ass");
|
||||
if (!options.SkipSubMux){
|
||||
args.AddRange(options.Subtitles.Select((sub, subindex) =>
|
||||
$"-metadata:s:s:{subindex} title=\"{sub.Language.Language ?? sub.Language.Name}{(sub.ClosedCaption == true ? $" {options.CcTag}" : "")}{(sub.Signs == true ? " Signs" : "")}\" -metadata:s:s:{subindex} language={sub.Language.Code}"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.VideoTitle)){
|
||||
args.Add($"-metadata title=\"{options.VideoTitle}\"");
|
||||
}
|
||||
|
||||
if (options.Description is{ Count: > 0 }){
|
||||
XmlDocument doc = new XmlDocument();
|
||||
doc.Load(options.Description[0].Path);
|
||||
XmlNode? node = doc.SelectSingleNode("//Tag/Simple[Name='DESCRIPTION']/String");
|
||||
string description = node?.InnerText
|
||||
.Replace("\\", "\\\\") // Escape backslashes
|
||||
.Replace("\"", "\\\"") // Escape double quotes
|
||||
?? string.Empty;
|
||||
args.Add($"-metadata comment=\"{description}\"");
|
||||
}
|
||||
|
||||
if (options.Options.ffmpeg?.Count > 0){
|
||||
args.AddRange(options.Options.ffmpeg);
|
||||
}
|
||||
|
||||
args.Add($"\"{options.Output}\"");
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
|
||||
if (options.OnlyAudio.Count > 1){
|
||||
Console.Error.WriteLine("Multiple audio files detected. Only one audio file can be converted to MP3 at a time.");
|
||||
}
|
||||
|
||||
var audio = options.OnlyAudio.First();
|
||||
|
||||
args.Add($"-i \"{audio.Path}\"");
|
||||
args.Add("-c:a libmp3lame" + (audio.Bitrate > 0 ? $" -b:a {audio.Bitrate}k" : ""));
|
||||
args.Add($"\"{options.Output}\"");
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
|
||||
public string MkvMerge(){
|
||||
List<string> args = new List<string>();
|
||||
|
||||
bool hasVideo = false;
|
||||
|
||||
args.Add($"-o \"{Helpers.AddUncPrefixIfNeeded(options.Output)}\"");
|
||||
if (options.Options.mkvmerge != null){
|
||||
args.AddRange(options.Options.mkvmerge);
|
||||
return new MkvMergeCommandBuilder(Options).Build();
|
||||
}
|
||||
|
||||
|
||||
foreach (var vid in options.OnlyVid){
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
args.Add("--video-tracks 0");
|
||||
args.Add("--no-audio");
|
||||
|
||||
string trackName = $"{(vid.Language.Name)}";
|
||||
args.Add($"--track-name 0:\"{trackName}\"");
|
||||
args.Add($"--language 0:{vid.Language.Code}");
|
||||
|
||||
hasVideo = true;
|
||||
args.Add($"\"{Helpers.AddUncPrefixIfNeeded(vid.Path)}\"");
|
||||
}
|
||||
}
|
||||
|
||||
// var sortedAudio = options.OnlyAudio
|
||||
// .OrderBy(sub => options.DubLangList.IndexOf(sub.Language.CrLocale) != -1 ? options.DubLangList.IndexOf(sub.Language.CrLocale) : int.MaxValue)
|
||||
// .ToList();
|
||||
|
||||
var rank = options.DubLangList
|
||||
.Select((val, i) => new{ val, i })
|
||||
.ToDictionary(x => x.val, x => x.i, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var sortedAudio = options.OnlyAudio
|
||||
.OrderBy(m => {
|
||||
var key = m.Language?.CrLocale ?? string.Empty;
|
||||
return rank.TryGetValue(key, out var r) ? r : int.MaxValue; // unknown locales last
|
||||
})
|
||||
.ThenBy(m => m.IsAudioRoleDescription) // false first, then true
|
||||
.ToList();
|
||||
|
||||
foreach (var aud in sortedAudio){
|
||||
string trackName = aud.Language.Name + (aud.IsAudioRoleDescription ? " [AD]" : "");
|
||||
args.Add("--audio-tracks 0");
|
||||
args.Add("--no-video");
|
||||
args.Add($"--track-name 0:\"{trackName}\"");
|
||||
args.Add($"--language 0:{aud.Language.Code}");
|
||||
|
||||
|
||||
if (options.Defaults.Audio.Code == aud.Language.Code && !aud.IsAudioRoleDescription){
|
||||
args.Add("--default-track 0");
|
||||
} else{
|
||||
args.Add("--default-track 0:0");
|
||||
}
|
||||
|
||||
if (aud.Delay != null && aud.Delay != 0){
|
||||
args.Add($"--sync 0:{aud.Delay}");
|
||||
}
|
||||
|
||||
args.Add($"\"{Helpers.AddUncPrefixIfNeeded(aud.Path)}\"");
|
||||
}
|
||||
|
||||
if (options.Subtitles.Count > 0 && !options.SkipSubMux){
|
||||
bool hasSignsSub = options.Subtitles.Any(sub => sub.Signs && options.Defaults.Sub.Code == sub.Language.Code);
|
||||
|
||||
var sortedSubtitles = options.Subtitles
|
||||
.OrderBy(sub => options.SubLangList.IndexOf(sub.Language.CrLocale) != -1
|
||||
? options.SubLangList.IndexOf(sub.Language.CrLocale)
|
||||
: int.MaxValue)
|
||||
.ThenBy(sub => sub.ClosedCaption ? 2 : sub.Signs ? 1 : 0)
|
||||
.ToList();
|
||||
|
||||
foreach (var subObj in sortedSubtitles){
|
||||
bool isForced = false;
|
||||
if (subObj.Delay.HasValue){
|
||||
double delay = subObj.Delay ?? 0;
|
||||
args.Add($"--sync 0:{delay}");
|
||||
}
|
||||
|
||||
string trackNameExtra = subObj.ClosedCaption ? $" {options.CcTag}" : "";
|
||||
trackNameExtra += subObj.Signs ? " Signs" : "";
|
||||
|
||||
string trackName = $"0:\"{(subObj.Language.Language ?? subObj.Language.Name) + trackNameExtra}\"";
|
||||
args.Add($"--track-name {trackName}");
|
||||
args.Add($"--language 0:\"{subObj.Language.Code}\"");
|
||||
|
||||
if (options.Defaults.Sub.Code == subObj.Language.Code &&
|
||||
(options.DefaultSubSigns == subObj.Signs || options.DefaultSubSigns && !hasSignsSub) && subObj.ClosedCaption == false){
|
||||
args.Add("--default-track 0");
|
||||
if (options.DefaultSubForcedDisplay){
|
||||
args.Add("--forced-track 0:yes");
|
||||
isForced = true;
|
||||
}
|
||||
} else{
|
||||
args.Add("--default-track 0:0");
|
||||
}
|
||||
|
||||
if (subObj.ClosedCaption && options.CcSubsMuxingFlag){
|
||||
args.Add("--hearing-impaired-flag 0:yes");
|
||||
}
|
||||
|
||||
if (subObj.Signs && options.SignsSubsAsForced && !isForced){
|
||||
args.Add("--forced-track 0:yes");
|
||||
}
|
||||
|
||||
args.Add($"\"{Helpers.AddUncPrefixIfNeeded(subObj.File)}\"");
|
||||
}
|
||||
} else{
|
||||
args.Add("--no-subtitles");
|
||||
}
|
||||
|
||||
if (options.Fonts is{ Count: > 0 }){
|
||||
foreach (var font in options.Fonts){
|
||||
args.Add($"--attachment-name \"{font.Name}\"");
|
||||
args.Add($"--attachment-mime-type \"{font.Mime}\"");
|
||||
args.Add($"--attach-file \"{Helpers.AddUncPrefixIfNeeded(font.Path)}\"");
|
||||
}
|
||||
} else{
|
||||
args.Add("--no-attachments");
|
||||
}
|
||||
|
||||
if (options.Chapters is{ Count: > 0 }){
|
||||
args.Add($"--chapters \"{Helpers.AddUncPrefixIfNeeded(options.Chapters[0].Path)}\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.VideoTitle)){
|
||||
args.Add($"--title \"{options.VideoTitle}\"");
|
||||
}
|
||||
|
||||
if (options.Description is{ Count: > 0 }){
|
||||
args.Add($"--global-tags \"{Helpers.AddUncPrefixIfNeeded(options.Description[0].Path)}\"");
|
||||
}
|
||||
|
||||
if (options.Cover.Count > 0){
|
||||
if (File.Exists(options.Cover.First().Path)){
|
||||
args.Add($"--attach-file \"{options.Cover.First().Path}\"");
|
||||
args.Add($"--attachment-mime-type image/png");
|
||||
args.Add($"--attachment-name cover.png");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
public async Task<double> ProcessVideo(string baseVideoPath, string compareVideoPath){
|
||||
string baseFramesDir, baseFramesDirEnd;
|
||||
string compareFramesDir, compareFramesDirEnd;
|
||||
string cleanupDir;
|
||||
try{
|
||||
var tempDir = CfgManager.PathTEMP_DIR;
|
||||
string uuid = Guid.NewGuid().ToString();
|
||||
|
||||
cleanupDir = Path.Combine(tempDir, uuid);
|
||||
baseFramesDir = Path.Combine(tempDir, uuid, "base_frames_start");
|
||||
baseFramesDirEnd = Path.Combine(tempDir, uuid, "base_frames_end");
|
||||
compareFramesDir = Path.Combine(tempDir, uuid, "compare_frames_start");
|
||||
compareFramesDirEnd = Path.Combine(tempDir, uuid, "compare_frames_end");
|
||||
|
||||
Directory.CreateDirectory(baseFramesDir);
|
||||
Directory.CreateDirectory(baseFramesDirEnd);
|
||||
Directory.CreateDirectory(compareFramesDir);
|
||||
Directory.CreateDirectory(compareFramesDirEnd);
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return -100;
|
||||
}
|
||||
|
||||
try{
|
||||
var extractFramesBaseStart = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDir, 0, 120);
|
||||
var extractFramesCompareStart = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDir, 0, 120);
|
||||
|
||||
TimeSpan? baseVideoDurationTimeSpan = await Helpers.GetMediaDurationAsync(CfgManager.PathFFMPEG, baseVideoPath);
|
||||
TimeSpan? compareVideoDurationTimeSpan = await Helpers.GetMediaDurationAsync(CfgManager.PathFFMPEG, compareVideoPath);
|
||||
|
||||
if (baseVideoDurationTimeSpan == null || compareVideoDurationTimeSpan == null){
|
||||
Console.Error.WriteLine("Failed to retrieve video durations");
|
||||
return -100;
|
||||
}
|
||||
|
||||
var extractFramesBaseEnd = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDirEnd, baseVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
||||
var extractFramesCompareEnd = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDirEnd, compareVideoDurationTimeSpan.Value.TotalSeconds - 360, 360);
|
||||
|
||||
if (!extractFramesBaseStart.IsOk || !extractFramesCompareStart.IsOk || !extractFramesBaseEnd.IsOk || !extractFramesCompareEnd.IsOk){
|
||||
Console.Error.WriteLine("Failed to extract Frames to Compare");
|
||||
return -100;
|
||||
}
|
||||
|
||||
|
||||
// Load frames from start of the videos
|
||||
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesBaseStart.frameRate)
|
||||
}).ToList();
|
||||
|
||||
var compareFramesStart = Directory.GetFiles(compareFramesDir).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareStart.frameRate)
|
||||
}).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();
|
||||
|
||||
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate)
|
||||
}).ToList();
|
||||
|
||||
|
||||
// Calculate offsets
|
||||
var startOffset = SyncingHelper.CalculateOffset(baseFramesStart, compareFramesStart);
|
||||
var endOffset = SyncingHelper.CalculateOffset(baseFramesEnd, compareFramesEnd, true);
|
||||
|
||||
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
|
||||
|
||||
endOffset += lengthDiff;
|
||||
|
||||
Console.WriteLine($"Start offset: {startOffset} seconds");
|
||||
Console.WriteLine($"End offset: {endOffset} seconds");
|
||||
|
||||
CleanupDirectory(cleanupDir);
|
||||
|
||||
baseFramesStart.Clear();
|
||||
baseFramesEnd.Clear();
|
||||
compareFramesStart.Clear();
|
||||
compareFramesEnd.Clear();
|
||||
|
||||
var difference = Math.Abs(startOffset - endOffset);
|
||||
|
||||
switch (difference){
|
||||
case < 0.1:
|
||||
return startOffset;
|
||||
case > 1:
|
||||
Console.Error.WriteLine($"Couldn't sync dub:");
|
||||
Console.Error.WriteLine($"\tStart offset: {startOffset} seconds");
|
||||
Console.Error.WriteLine($"\tEnd offset: {endOffset} seconds");
|
||||
Console.Error.WriteLine($"\tVideo length difference: {lengthDiff} seconds");
|
||||
return -100;
|
||||
default:
|
||||
return endOffset;
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return -100;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupDirectory(string dirPath){
|
||||
if (Directory.Exists(dirPath)){
|
||||
Directory.Delete(dirPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetTimeFromFileName(string fileName, double frameRate){
|
||||
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
|
||||
if (match.Success){
|
||||
return int.Parse(match.Groups[1].Value) / frameRate;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
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(),
|
||||
|
|
@ -422,7 +41,7 @@ public class Merger{
|
|||
}
|
||||
|
||||
Console.WriteLine($"[{type}] Started merging");
|
||||
var result = await Helpers.ExecuteCommandAsync(type, 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");
|
||||
|
|
@ -440,111 +59,21 @@ public class Merger{
|
|||
|
||||
public void CleanUp(){
|
||||
// Combine all media file lists and iterate through them
|
||||
var allMediaFiles = options.OnlyAudio.Concat(options.OnlyVid)
|
||||
var allMediaFiles = Options.OnlyAudio.Concat(Options.OnlyVid).Concat(Options.VideoAndAudio)
|
||||
.ToList();
|
||||
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path));
|
||||
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".resume"));
|
||||
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".new.resume"));
|
||||
|
||||
options.Description?.ForEach(description => Helpers.DeleteFile(description.Path));
|
||||
|
||||
options.Cover?.ForEach(cover => Helpers.DeleteFile(cover.Path));
|
||||
Options.Description?.ForEach(description => Helpers.DeleteFile(description.Path));
|
||||
Options.Cover.ForEach(cover => Helpers.DeleteFile(cover.Path));
|
||||
|
||||
// Delete chapter files if any
|
||||
options.Chapters?.ForEach(chapter => Helpers.DeleteFile(chapter.Path));
|
||||
Options.Chapters?.ForEach(chapter => Helpers.DeleteFile(chapter.Path));
|
||||
|
||||
if (!options.SkipSubMux){
|
||||
if (!Options.SkipSubMux){
|
||||
// Delete subtitle files
|
||||
options.Subtitles.ForEach(subtitle => Helpers.DeleteFile(subtitle.File));
|
||||
Options.Subtitles.ForEach(subtitle => Helpers.DeleteFile(subtitle.File));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MergerInput{
|
||||
public string Path{ get; set; }
|
||||
public LanguageItem Language{ get; set; }
|
||||
public int? Duration{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
public bool IsAudioRoleDescription{ get; set; }
|
||||
public int? Bitrate{ get; set; }
|
||||
}
|
||||
|
||||
public class SubtitleInput{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public string File{ get; set; }
|
||||
public bool ClosedCaption{ get; set; }
|
||||
public bool Signs{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
|
||||
public DownloadedMedia? RelatedVideoDownloadMedia;
|
||||
}
|
||||
|
||||
public class ParsedFont{
|
||||
public string Name{ get; set; }
|
||||
public string Path{ get; set; }
|
||||
public string Mime{ get; set; }
|
||||
}
|
||||
|
||||
public class CrunchyMuxOptions{
|
||||
public List<string> DubLangList{ get; set; } = new List<string>();
|
||||
public List<string> SubLangList{ get; set; } = new List<string>();
|
||||
public string Output{ get; set; }
|
||||
public bool SkipSubMux{ get; set; }
|
||||
public bool? KeepAllVideos{ get; set; }
|
||||
public bool? Novids{ get; set; }
|
||||
public bool Mp4{ get; set; }
|
||||
public bool Mp3{ get; set; }
|
||||
public bool MuxFonts{ get; set; }
|
||||
public bool MuxCover{ get; set; }
|
||||
public bool MuxDescription{ get; set; }
|
||||
public string ForceMuxer{ get; set; }
|
||||
public bool? NoCleanup{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public List<string> FfmpegOptions{ get; set; } = new List<string>();
|
||||
public List<string> MkvmergeOptions{ get; set; } = new List<string>();
|
||||
public LanguageItem DefaultSub{ get; set; }
|
||||
public LanguageItem DefaultAudio{ get; set; }
|
||||
public string CcTag{ get; set; }
|
||||
public bool SyncTiming{ get; set; }
|
||||
public bool DlVideoOnce{ get; set; }
|
||||
|
||||
public bool DefaultSubSigns{ get; set; }
|
||||
|
||||
public bool DefaultSubForcedDisplay{ get; set; }
|
||||
public bool CcSubsMuxingFlag{ get; set; }
|
||||
public bool SignsSubsAsForced{ get; set; }
|
||||
}
|
||||
|
||||
public class MergerOptions{
|
||||
public List<string> DubLangList{ get; set; } = new List<string>();
|
||||
public List<string> SubLangList{ get; set; } = new List<string>();
|
||||
public List<MergerInput> OnlyVid{ get; set; } = new List<MergerInput>();
|
||||
public List<MergerInput> OnlyAudio{ get; set; } = new List<MergerInput>();
|
||||
public List<SubtitleInput> Subtitles{ get; set; } = new List<SubtitleInput>();
|
||||
public List<MergerInput> Chapters{ get; set; } = new List<MergerInput>();
|
||||
public string CcTag{ get; set; }
|
||||
public string Output{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public bool? KeepAllVideos{ get; set; }
|
||||
public List<ParsedFont> Fonts{ get; set; } = new List<ParsedFont>();
|
||||
public bool SkipSubMux{ get; set; }
|
||||
public MuxOptions Options{ get; set; }
|
||||
public Defaults Defaults{ get; set; }
|
||||
public bool mp3{ get; set; }
|
||||
public bool DefaultSubSigns{ get; set; }
|
||||
public bool DefaultSubForcedDisplay{ get; set; }
|
||||
public bool CcSubsMuxingFlag{ get; set; }
|
||||
public bool SignsSubsAsForced{ get; set; }
|
||||
public List<MergerInput> Description{ get; set; } = new List<MergerInput>();
|
||||
public List<MergerInput> Cover{ get; set; } =[];
|
||||
}
|
||||
|
||||
public class MuxOptions{
|
||||
public List<string>? ffmpeg{ get; set; }
|
||||
public List<string>? mkvmerge{ get; set; }
|
||||
}
|
||||
|
||||
public class Defaults{
|
||||
public LanguageItem Audio{ get; set; }
|
||||
public LanguageItem Sub{ get; set; }
|
||||
}
|
||||
34
CRD/Utils/Muxing/Structs/CrunchyMuxOptions.cs
Normal file
34
CRD/Utils/Muxing/Structs/CrunchyMuxOptions.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Collections.Generic;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Structs;
|
||||
|
||||
public class CrunchyMuxOptions{
|
||||
public required List<string> DubLangList{ get; set; } = [];
|
||||
public required List<string> SubLangList{ get; set; } = [];
|
||||
public string Output{ get; set; }
|
||||
public bool SkipSubMux{ get; set; }
|
||||
public bool KeepAllVideos{ get; set; }
|
||||
public bool Novids{ get; set; }
|
||||
public bool Mp4{ get; set; }
|
||||
public bool Mp3{ get; set; }
|
||||
public bool MuxFonts{ get; set; }
|
||||
public bool MuxCover{ get; set; }
|
||||
public bool MuxDescription{ get; set; }
|
||||
public string ForceMuxer{ get; set; }
|
||||
public bool NoCleanup{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public List<string> FfmpegOptions{ get; set; } = [];
|
||||
public List<string> MkvmergeOptions{ get; set; } = [];
|
||||
public LanguageItem? DefaultSub{ get; set; }
|
||||
public LanguageItem? DefaultAudio{ get; set; }
|
||||
public string CcTag{ get; set; }
|
||||
public bool SyncTiming{ get; set; }
|
||||
|
||||
public bool DlVideoOnce{ get; set; }
|
||||
|
||||
public bool DefaultSubSigns{ get; set; }
|
||||
public bool DefaultSubForcedDisplay{ get; set; }
|
||||
public bool CcSubsMuxingFlag{ get; set; }
|
||||
public bool SignsSubsAsForced{ get; set; }
|
||||
}
|
||||
12
CRD/Utils/Muxing/Structs/MergerInput.cs
Normal file
12
CRD/Utils/Muxing/Structs/MergerInput.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Structs;
|
||||
|
||||
public class MergerInput{
|
||||
public string Path{ get; set; }
|
||||
public LanguageItem Language{ get; set; }
|
||||
public int? Duration{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
public bool IsAudioRoleDescription{ get; set; }
|
||||
public int? Bitrate{ get; set; }
|
||||
}
|
||||
39
CRD/Utils/Muxing/Structs/MergerOptions.cs
Normal file
39
CRD/Utils/Muxing/Structs/MergerOptions.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using System.Collections.Generic;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Structs;
|
||||
|
||||
public class MergerOptions{
|
||||
public List<string> DubLangList{ get; set; } = [];
|
||||
public List<string> SubLangList{ get; set; } = [];
|
||||
public List<MergerInput> OnlyVid{ get; set; } = [];
|
||||
public List<MergerInput> OnlyAudio{ get; set; } = [];
|
||||
public List<SubtitleInput> Subtitles{ get; set; } = [];
|
||||
public List<MergerInput> Chapters{ get; set; } = [];
|
||||
public string CcTag{ get; set; }
|
||||
public string Output{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public bool KeepAllVideos{ get; set; }
|
||||
public List<ParsedFont> Fonts{ get; set; } = [];
|
||||
public bool SkipSubMux{ get; set; }
|
||||
public MuxOptions Options{ get; set; }
|
||||
public Defaults Defaults{ get; set; }
|
||||
public bool mp3{ get; set; }
|
||||
public bool DefaultSubSigns{ get; set; }
|
||||
public bool DefaultSubForcedDisplay{ get; set; }
|
||||
public bool CcSubsMuxingFlag{ get; set; }
|
||||
public bool SignsSubsAsForced{ get; set; }
|
||||
public List<MergerInput> Description{ get; set; } = [];
|
||||
public List<MergerInput> Cover{ get; set; } = [];
|
||||
public List<MergerInput> VideoAndAudio{ get; set; } = [];
|
||||
}
|
||||
|
||||
public class Defaults{
|
||||
public LanguageItem? Audio{ get; set; }
|
||||
public LanguageItem? Sub{ get; set; }
|
||||
}
|
||||
|
||||
public class MuxOptions{
|
||||
public List<string>? Ffmpeg{ get; set; }
|
||||
public List<string>? Mkvmerge{ get; set; }
|
||||
}
|
||||
7
CRD/Utils/Muxing/Structs/ParsedFont.cs
Normal file
7
CRD/Utils/Muxing/Structs/ParsedFont.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace CRD.Utils.Muxing.Structs;
|
||||
|
||||
public class ParsedFont{
|
||||
public string Name{ get; set; }
|
||||
public string Path{ get; set; }
|
||||
public string Mime{ get; set; }
|
||||
}
|
||||
13
CRD/Utils/Muxing/Structs/SubtitleInput.cs
Normal file
13
CRD/Utils/Muxing/Structs/SubtitleInput.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Structs;
|
||||
|
||||
public class SubtitleInput{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public string File{ get; set; }
|
||||
public bool ClosedCaption{ get; set; }
|
||||
public bool Signs{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
|
||||
public DownloadedMedia? RelatedVideoDownloadMedia;
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Structs;
|
||||
|
|
@ -13,7 +14,7 @@ using SixLabors.ImageSharp.PixelFormats;
|
|||
using SixLabors.ImageSharp.Processing;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace CRD.Utils.Muxing;
|
||||
namespace CRD.Utils.Muxing.Syncing;
|
||||
|
||||
public class SyncingHelper{
|
||||
public static async Task<(bool IsOk, int ErrorCode, double frameRate)> ExtractFrames(string videoPath, string outputDir, double offset, double duration){
|
||||
|
|
@ -212,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);
|
||||
142
CRD/Utils/Muxing/Syncing/VideoSyncer.cs
Normal file
142
CRD/Utils/Muxing/Syncing/VideoSyncer.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing.Syncing;
|
||||
|
||||
public class VideoSyncer{
|
||||
public static VideoSyncer Instance{ get; } = new();
|
||||
|
||||
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.Empty;
|
||||
double baseEndWindowOffset = 0;
|
||||
double compareEndWindowOffset = 0;
|
||||
try{
|
||||
var tempDir = CfgManager.PathTEMP_DIR;
|
||||
string uuid = Guid.NewGuid().ToString();
|
||||
|
||||
cleanupDir = Path.Combine(tempDir, uuid);
|
||||
baseFramesDir = Path.Combine(tempDir, uuid, "base_frames_start");
|
||||
baseFramesDirEnd = Path.Combine(tempDir, uuid, "base_frames_end");
|
||||
compareFramesDir = Path.Combine(tempDir, uuid, "compare_frames_start");
|
||||
compareFramesDirEnd = Path.Combine(tempDir, uuid, "compare_frames_end");
|
||||
|
||||
Directory.CreateDirectory(baseFramesDir);
|
||||
Directory.CreateDirectory(baseFramesDirEnd);
|
||||
Directory.CreateDirectory(compareFramesDir);
|
||||
Directory.CreateDirectory(compareFramesDirEnd);
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return (-100, 0, 0, 0);
|
||||
}
|
||||
|
||||
try{
|
||||
var extractFramesBaseStart = await SyncingHelper.ExtractFrames(baseVideoPath, baseFramesDir, 0, 120);
|
||||
var extractFramesCompareStart = await SyncingHelper.ExtractFrames(compareVideoPath, compareFramesDir, 0, 120);
|
||||
|
||||
TimeSpan? baseVideoDurationTimeSpan = await Helpers.GetMediaDurationAsync(CfgManager.PathFFMPEG, baseVideoPath);
|
||||
TimeSpan? compareVideoDurationTimeSpan = await Helpers.GetMediaDurationAsync(CfgManager.PathFFMPEG, compareVideoPath);
|
||||
|
||||
if (baseVideoDurationTimeSpan == null || compareVideoDurationTimeSpan == null){
|
||||
Console.Error.WriteLine("Failed to retrieve video durations");
|
||||
return (-100, 0, 0, 0);
|
||||
}
|
||||
|
||||
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");
|
||||
return (-100, 0, 0, 0);
|
||||
}
|
||||
|
||||
// Load frames from start of the videos
|
||||
var baseFramesStart = Directory.GetFiles(baseFramesDir).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
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, 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, baseEndWindowOffset)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
var compareFramesEnd = Directory.GetFiles(compareFramesDirEnd).Select(fp => new FrameData{
|
||||
FilePath = fp,
|
||||
Time = GetTimeFromFileName(fp, extractFramesCompareEnd.frameRate, compareEndWindowOffset)
|
||||
}).OrderBy(frame => frame.Time).ToList();
|
||||
|
||||
|
||||
// Calculate offsets
|
||||
var startOffset = SyncingHelper.CalculateOffset(baseFramesStart, compareFramesStart);
|
||||
var endOffset = SyncingHelper.CalculateOffset(baseFramesEnd, compareFramesEnd, true);
|
||||
|
||||
var lengthDiff = (baseVideoDurationTimeSpan.Value.TotalMicroseconds - compareVideoDurationTimeSpan.Value.TotalMicroseconds) / 1000000;
|
||||
|
||||
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");
|
||||
|
||||
baseFramesStart.Clear();
|
||||
baseFramesEnd.Clear();
|
||||
compareFramesStart.Clear();
|
||||
compareFramesEnd.Clear();
|
||||
|
||||
var difference = Math.Abs(startOffset - endOffset);
|
||||
|
||||
switch (difference){
|
||||
case < 0.1:
|
||||
return (startOffset, startOffset, endOffset, lengthDiff);
|
||||
case > 1:
|
||||
Console.Error.WriteLine($"Couldn't sync dub:");
|
||||
Console.Error.WriteLine($"\tStart offset: {startOffset} seconds");
|
||||
Console.Error.WriteLine($"\tEnd offset: {endOffset} seconds");
|
||||
Console.Error.WriteLine($"\tVideo length difference: {lengthDiff} seconds");
|
||||
return (-100, startOffset, endOffset, lengthDiff);
|
||||
default:
|
||||
return (endOffset, startOffset, endOffset, lengthDiff);
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine(e);
|
||||
return (-100, 0, 0, 0);
|
||||
} finally{
|
||||
CleanupDirectory(cleanupDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupDirectory(string dirPath){
|
||||
if (!string.IsNullOrEmpty(dirPath) && Directory.Exists(dirPath)){
|
||||
Directory.Delete(dirPath, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static double GetTimeFromFileName(string fileName, double frameRate, double timeOffset){
|
||||
var match = Regex.Match(Path.GetFileName(fileName), @"frame(\d+)");
|
||||
if (match.Success){
|
||||
return timeOffset + int.Parse(match.Groups[1].Value) / frameRate;
|
||||
}
|
||||
|
||||
return timeOffset;
|
||||
}
|
||||
}
|
||||
|
|
@ -216,7 +216,7 @@ public class ToM3u8Class{
|
|||
}
|
||||
|
||||
public static dynamic FormatVttPlaylist(dynamic item){
|
||||
if (ObjectUtilities.GetMemberValue(item,"segments") == null){
|
||||
if (ObjectUtilities.GetMemberValue(item, "segments") == null){
|
||||
// VTT tracks may use a single file in BaseURL
|
||||
var segment = new ExpandoObject() as IDictionary<string, object>;
|
||||
segment["uri"] = item.attributes.baseUrl;
|
||||
|
|
@ -237,8 +237,7 @@ public class ToM3u8Class{
|
|||
m3u8Attributes["PROGRAM-ID"] = 1;
|
||||
|
||||
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes,"codecs") != null){
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "codecs") != null){
|
||||
m3u8Attributes["CODECS"] = item.attributes.codecs;
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +251,7 @@ public class ToM3u8Class{
|
|||
vttPlaylist.timelineStarts = item.attributes.timelineStarts;
|
||||
vttPlaylist.discontinuityStarts = item.discontinuityStarts;
|
||||
vttPlaylist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence");
|
||||
vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item,"mediaSequence");
|
||||
vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item, "mediaSequence");
|
||||
vttPlaylist.segments = item.segments;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){
|
||||
|
|
@ -365,7 +364,7 @@ public class ToM3u8Class{
|
|||
}
|
||||
|
||||
return allPlaylists.Select(playlist => {
|
||||
playlist.discontinuityStarts = FindIndexes((List<dynamic>) ObjectUtilities.GetMemberValue(playlists,"segments") ?? new List<dynamic>(), "discontinuity");
|
||||
playlist.discontinuityStarts = FindIndexes((List<dynamic>)ObjectUtilities.GetMemberValue(playlists, "segments") ?? new List<dynamic>(), "discontinuity");
|
||||
return playlist;
|
||||
}).ToList();
|
||||
}
|
||||
|
|
@ -442,21 +441,26 @@ public class ToM3u8Class{
|
|||
public static void AddMediaSequenceValues(List<dynamic> playlists, List<dynamic> timelineStarts){
|
||||
foreach (var playlist in playlists){
|
||||
playlist.mediaSequence = 0;
|
||||
playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
|
||||
|
||||
if (playlist.segments == null) continue;
|
||||
playlist.discontinuitySequence =
|
||||
timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
|
||||
|
||||
for (int i = 0; i < playlist.segments.Count; i++){
|
||||
playlist.segments[i].number = i;
|
||||
var segments = playlist.segments as List<dynamic>;
|
||||
if (segments == null) continue;
|
||||
|
||||
for (int i = 0; i < segments.Count; i++){
|
||||
segments[i].number = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static List<int> FindIndexes(List<dynamic> list, string key){
|
||||
var indexes = new List<int>();
|
||||
var indexes = new List<int>(list.Count);
|
||||
|
||||
for (int i = 0; i < list.Count; i++){
|
||||
var expandoDict = list[i] as IDictionary<string, object>;
|
||||
if (expandoDict != null && expandoDict.ContainsKey(key) && expandoDict[key] != null){
|
||||
if (list[i] is IDictionary<string, object?> dict &&
|
||||
dict.TryGetValue(key, out var value) &&
|
||||
value != null){
|
||||
indexes.Add(i);
|
||||
}
|
||||
}
|
||||
|
|
@ -464,33 +468,50 @@ public class ToM3u8Class{
|
|||
return indexes;
|
||||
}
|
||||
|
||||
public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, IDictionary<string, dynamic> sidxMapping){
|
||||
string sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx"));
|
||||
if (!string.IsNullOrEmpty(sidxKey) && sidxMapping.ContainsKey(sidxKey)){
|
||||
var sidxMatch = sidxMapping[sidxKey];
|
||||
if (sidxMatch != null){
|
||||
SegmentBase.AddSidxSegmentsToPlaylist(playlist, sidxMatch.sidx, playlist.sidx.resolvedUri);
|
||||
}
|
||||
}
|
||||
public static dynamic AddSidxSegmentsToPlaylist(
|
||||
dynamic playlist,
|
||||
IDictionary<string, dynamic> sidxMapping){
|
||||
string? sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx"));
|
||||
|
||||
if (string.IsNullOrEmpty(sidxKey))
|
||||
return playlist;
|
||||
|
||||
if (!sidxMapping.TryGetValue(sidxKey, out var sidxMatch) || sidxMatch == null)
|
||||
return playlist;
|
||||
|
||||
SegmentBase.AddSidxSegmentsToPlaylist(
|
||||
playlist,
|
||||
sidxMatch?.sidx,
|
||||
ObjectUtilities.GetMemberValue(playlist.sidx, "resolvedUri"));
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public static List<dynamic> AddSidxSegmentsToPlaylists(List<dynamic> playlists, IDictionary<string, dynamic>? sidxMapping = null){
|
||||
public static List<dynamic> AddSidxSegmentsToPlaylists(
|
||||
List<dynamic> playlists,
|
||||
IDictionary<string, dynamic>? sidxMapping = null){
|
||||
sidxMapping ??= new Dictionary<string, dynamic>();
|
||||
|
||||
if (sidxMapping.Count == 0){
|
||||
if (sidxMapping.Count == 0)
|
||||
return playlists;
|
||||
}
|
||||
|
||||
for (int i = 0; i < playlists.Count; i++){
|
||||
playlists[i] = AddSidxSegmentsToPlaylist(playlists[i], sidxMapping);
|
||||
foreach (var playlist in playlists){
|
||||
AddSidxSegmentsToPlaylist(playlist, sidxMapping);
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
public static string GenerateSidxKey(dynamic sidx){
|
||||
return sidx != null ? $"{sidx.uri}-{UrlType.ByteRangeToString(sidx.byterange)}" : null;
|
||||
public static string? GenerateSidxKey(dynamic sidx){
|
||||
if (sidx == null)
|
||||
return null;
|
||||
|
||||
var uri = ObjectUtilities.GetMemberValue(sidx, "uri");
|
||||
var byteRange = ObjectUtilities.GetMemberValue(sidx, "ByteRange");
|
||||
|
||||
if (uri == null || byteRange == null)
|
||||
return null;
|
||||
|
||||
return $"{uri}-{UrlType.ByteRangeToString(byteRange)}";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Linq;
|
||||
using CRD.Utils.DRM;
|
||||
using CRD.Utils.HLS;
|
||||
using CRD.Utils.Parser;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils;
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class Segment{
|
||||
public string uri{ get; set; }
|
||||
|
|
@ -16,40 +20,41 @@ public class Segment{
|
|||
public double duration{ get; set; }
|
||||
public Map map{ get; set; }
|
||||
|
||||
public ByteRange? byteRange { get; set; }
|
||||
public ByteRange? byteRange{ get; set; }
|
||||
public double? number{ get; set; }
|
||||
public double? presentationTime{ get; set; }
|
||||
}
|
||||
|
||||
public class Map{
|
||||
public string uri { get; set; }
|
||||
public string uri{ get; set; }
|
||||
|
||||
public ByteRange? byteRange { get; set; }
|
||||
public ByteRange? byteRange{ get; set; }
|
||||
}
|
||||
|
||||
public class PlaylistItem{
|
||||
public string? pssh{ get; set; }
|
||||
public List<ContentKey> encryptionKeys{ get; set; } =[];
|
||||
|
||||
public List<ContentKey> encryptionKeys{ get; set; } = [];
|
||||
public int bandwidth{ get; set; }
|
||||
public List<Segment> segments{ get; set; }
|
||||
}
|
||||
|
||||
public class AudioPlaylist : PlaylistItem{
|
||||
public LanguageItem? language{ get; set; }
|
||||
|
||||
public int audioSamplingRate{ get; set; }
|
||||
public bool @default{ get; set; }
|
||||
}
|
||||
|
||||
public class VideoPlaylist : PlaylistItem{
|
||||
public Quality quality{ get; set; }
|
||||
public string codecs{ get; set; }
|
||||
}
|
||||
|
||||
public class VideoItem: VideoPlaylist{
|
||||
public class VideoItem : VideoPlaylist{
|
||||
public string resolutionText{ get; set; }
|
||||
}
|
||||
|
||||
public class AudioItem: AudioPlaylist{
|
||||
public class AudioItem : AudioPlaylist{
|
||||
public string resolutionText{ get; set; }
|
||||
public string resolutionTextSnap{ get; set; }
|
||||
}
|
||||
|
|
@ -65,16 +70,16 @@ public class MPDParsed{
|
|||
|
||||
public class ServerData{
|
||||
public List<string> servers{ get; set; } = [];
|
||||
public List<AudioPlaylist>? audio{ get; set; } =[];
|
||||
public List<VideoPlaylist>? video{ get; set; } =[];
|
||||
public List<AudioPlaylist>? audio{ get; set; } = [];
|
||||
public List<VideoPlaylist>? video{ get; set; } = [];
|
||||
}
|
||||
|
||||
public static class MPDParser{
|
||||
public static MPDParsed Parse(string manifest, LanguageItem? language, string? url){
|
||||
public static class MpdParser{
|
||||
public async static Task<MPDParsed> Parse(string manifest, LanguageItem? language, string? url){
|
||||
if (!manifest.Contains("BaseURL") && url != null){
|
||||
XDocument doc = XDocument.Parse(manifest);
|
||||
XElement mpd = doc.Element("MPD");
|
||||
mpd.AddFirst(new XElement("BaseURL", url));
|
||||
XElement? mpd = doc.Element("MPD");
|
||||
mpd?.AddFirst(new XElement("BaseURL", url));
|
||||
manifest = doc.ToString();
|
||||
}
|
||||
|
||||
|
|
@ -84,90 +89,295 @@ public static class MPDParser{
|
|||
|
||||
foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){
|
||||
foreach (var playlist in item.playlists){
|
||||
var host = new Uri(playlist.resolvedUri).Host;
|
||||
var uri = new Uri(playlist.resolvedUri);
|
||||
var host = uri.Host;
|
||||
|
||||
EnsureHostEntryExists(ret, host);
|
||||
|
||||
List<dynamic> segments = playlist.segments;
|
||||
List<Segment>? segmentsFromSidx = null;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
|
||||
throw new NotImplementedException();
|
||||
if (ObjectUtilities.GetMemberValue(playlist, "sidx") != null){
|
||||
if (segments == null || segments.Count == 0){
|
||||
var sidxRange = playlist.sidx.ByteRange;
|
||||
|
||||
var sidxBytes = await DownloadSidxAsync(
|
||||
HttpClientReq.Instance.GetHttpClient(),
|
||||
playlist.sidx.uri,
|
||||
sidxRange.offset,
|
||||
sidxRange.offset + sidxRange.length - 1);
|
||||
|
||||
var sidx = ParseSidx(sidxBytes, sidxRange.offset);
|
||||
|
||||
var byteRange = new ByteRange(){
|
||||
Length = playlist.sidx.map.ByteRange.length,
|
||||
Offset = playlist.sidx.map.ByteRange.offset,
|
||||
};
|
||||
|
||||
segmentsFromSidx = BuildSegmentsFromSidx(
|
||||
sidx,
|
||||
playlist.resolvedUri,
|
||||
byteRange);
|
||||
}
|
||||
}
|
||||
|
||||
var foundLanguage = Languages.FindLang(Languages.languages.FirstOrDefault(a => a.CrLocale == item.language)?.CrLocale ?? "unknown");
|
||||
LanguageItem? audioLang = item.language != null ? foundLanguage : (language != null ? language : foundLanguage);
|
||||
|
||||
var foundLanguage =
|
||||
Languages.FindLang(
|
||||
Languages.languages.FirstOrDefault(a => a.CrLocale == item.language)?.CrLocale ?? "unknown"
|
||||
);
|
||||
|
||||
LanguageItem? audioLang =
|
||||
item.language != null
|
||||
? foundLanguage
|
||||
: (language ?? foundLanguage);
|
||||
|
||||
var pItem = new AudioPlaylist{
|
||||
bandwidth = playlist.attributes.BANDWIDTH,
|
||||
audioSamplingRate = ObjectUtilities.GetMemberValue(playlist.attributes ,"AUDIOSAMPLINGRATE") ?? 0,
|
||||
audioSamplingRate = ObjectUtilities.GetMemberValue(playlist.attributes, "AUDIOSAMPLINGRATE") ?? 0,
|
||||
language = audioLang,
|
||||
@default = item.@default,
|
||||
segments = segments.Select(segment => new Segment{
|
||||
duration = segment.duration,
|
||||
map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")},
|
||||
number = segment.number,
|
||||
presentationTime = segment.presentationTime,
|
||||
timeline = segment.timeline,
|
||||
uri = segment.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment,"byterange")
|
||||
}).ToList()
|
||||
segments = segmentsFromSidx ?? ConvertSegments(segments)
|
||||
};
|
||||
|
||||
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
|
||||
pItem.pssh = ExtractWidevinePssh(playlist);
|
||||
|
||||
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null)
|
||||
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
|
||||
|
||||
ret.Data[host].audio.Add(pItem);
|
||||
ret.Data[host].audio?.Add(pItem);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var playlist in parsed.playlists){
|
||||
var host = new Uri(playlist.resolvedUri).Host;
|
||||
var uri = new Uri(playlist.resolvedUri);
|
||||
var host = uri.Host;
|
||||
|
||||
EnsureHostEntryExists(ret, host);
|
||||
|
||||
List<dynamic> segments = playlist.segments;
|
||||
List<Segment>? segmentsFromSidx = null;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
|
||||
throw new NotImplementedException();
|
||||
if (ObjectUtilities.GetMemberValue(playlist, "sidx") != null){
|
||||
if (segments == null || segments.Count == 0){
|
||||
var sidxRange = playlist.sidx.ByteRange;
|
||||
|
||||
var sidxBytes = await DownloadSidxAsync(
|
||||
HttpClientReq.Instance.GetHttpClient(),
|
||||
playlist.sidx.uri,
|
||||
sidxRange.offset,
|
||||
sidxRange.offset + sidxRange.length - 1);
|
||||
|
||||
var sidx = ParseSidx(sidxBytes, sidxRange.offset);
|
||||
|
||||
var byteRange = new ByteRange(){
|
||||
Length = playlist.sidx.map.ByteRange.length,
|
||||
Offset = playlist.sidx.map.ByteRange.offset,
|
||||
};
|
||||
|
||||
segmentsFromSidx = BuildSegmentsFromSidx(
|
||||
sidx,
|
||||
playlist.resolvedUri,
|
||||
byteRange);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dynamic resolution = ObjectUtilities.GetMemberValue(playlist.attributes,"RESOLUTION");
|
||||
resolution = resolution != null ? resolution : new Quality();
|
||||
dynamic resolution =
|
||||
ObjectUtilities.GetMemberValue(playlist.attributes, "RESOLUTION") ?? new Quality();
|
||||
|
||||
var pItem = new VideoPlaylist{
|
||||
bandwidth = playlist.attributes.BANDWIDTH,
|
||||
quality = new Quality{height = resolution.height,width = resolution.width},
|
||||
segments = segments.Select(segment => new Segment{
|
||||
duration = segment.duration,
|
||||
map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")},
|
||||
number = segment.number,
|
||||
presentationTime = segment.presentationTime,
|
||||
timeline = segment.timeline,
|
||||
uri = segment.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment,"byterange")
|
||||
}).ToList()
|
||||
codecs = ObjectUtilities.GetMemberValue(playlist.attributes, "CODECS") ?? "",
|
||||
quality = new Quality{
|
||||
height = resolution.height,
|
||||
width = resolution.width
|
||||
},
|
||||
segments = segmentsFromSidx ?? ConvertSegments(segments)
|
||||
};
|
||||
|
||||
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
|
||||
pItem.pssh = ExtractWidevinePssh(playlist);
|
||||
|
||||
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null)
|
||||
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
|
||||
|
||||
|
||||
ret.Data[host].video.Add(pItem);
|
||||
ret.Data[host].video?.Add(pItem);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static List<Segment> ConvertSegments(List<dynamic>? segments){
|
||||
return segments?.Select(segment => new Segment{
|
||||
duration = segment.duration,
|
||||
timeline = segment.timeline,
|
||||
number = segment.number,
|
||||
presentationTime = segment.presentationTime,
|
||||
uri = segment.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment, "byterange"),
|
||||
|
||||
map = new Map{
|
||||
uri = segment.map.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment.map, "byterange")
|
||||
}
|
||||
}).ToList() ?? [];
|
||||
}
|
||||
|
||||
private static string? ExtractWidevinePssh(dynamic playlist){
|
||||
var dict =
|
||||
ObjectUtilities.GetMemberValue(playlist, "contentProtection")
|
||||
as IDictionary<string, dynamic>;
|
||||
|
||||
if (dict == null)
|
||||
return null;
|
||||
|
||||
if (!dict.TryGetValue("com.widevine.alpha", out var widevine))
|
||||
return null;
|
||||
|
||||
if (widevine.pssh == null)
|
||||
return null;
|
||||
|
||||
return Convert.ToBase64String(widevine.pssh);
|
||||
}
|
||||
|
||||
private static void EnsureHostEntryExists(MPDParsed ret, string host){
|
||||
if (!ret.Data.ContainsKey(host)){
|
||||
ret.Data[host] = new ServerData{ audio = new List<AudioPlaylist>(), video = new List<VideoPlaylist>() };
|
||||
ret.Data[host] = new ServerData{
|
||||
audio = new List<AudioPlaylist>(),
|
||||
video = new List<VideoPlaylist>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static string ArrayBufferToBase64(byte[] buffer){
|
||||
return Convert.ToBase64String(buffer);
|
||||
public static List<Segment> BuildSegmentsFromSidx(
|
||||
SidxInfo sidx,
|
||||
string uri,
|
||||
ByteRange mapRange){
|
||||
var segments = new List<Segment>();
|
||||
|
||||
foreach (var r in sidx.References){
|
||||
segments.Add(new Segment{
|
||||
uri = uri,
|
||||
duration = (double)r.Duration / sidx.Timescale,
|
||||
presentationTime = r.PresentationTime,
|
||||
timeline = 0,
|
||||
|
||||
map = new Map{
|
||||
uri = uri,
|
||||
byteRange = mapRange
|
||||
},
|
||||
|
||||
byteRange = new ByteRange{
|
||||
Offset = r.Offset,
|
||||
Length = r.Size
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
static uint ReadUInt32BE(BinaryReader reader){
|
||||
var b = reader.ReadBytes(4);
|
||||
return (uint)(b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]);
|
||||
}
|
||||
|
||||
static ushort ReadUInt16BE(BinaryReader reader){
|
||||
var b = reader.ReadBytes(2);
|
||||
return (ushort)(b[0] << 8 | b[1]);
|
||||
}
|
||||
|
||||
static ulong ReadUInt64BE(BinaryReader reader){
|
||||
var b = reader.ReadBytes(8);
|
||||
|
||||
return
|
||||
((ulong)b[0] << 56) |
|
||||
((ulong)b[1] << 48) |
|
||||
((ulong)b[2] << 40) |
|
||||
((ulong)b[3] << 32) |
|
||||
((ulong)b[4] << 24) |
|
||||
((ulong)b[5] << 16) |
|
||||
((ulong)b[6] << 8) |
|
||||
b[7];
|
||||
}
|
||||
|
||||
public static SidxInfo ParseSidx(byte[] data, long sidxOffset){
|
||||
using var ms = new MemoryStream(data);
|
||||
using var reader = new BinaryReader(ms);
|
||||
|
||||
uint size = ReadUInt32BE(reader);
|
||||
string type = new string(reader.ReadChars(4));
|
||||
|
||||
if (type != "sidx")
|
||||
throw new Exception("Not a SIDX box");
|
||||
|
||||
byte version = reader.ReadByte();
|
||||
reader.ReadBytes(3); // flags
|
||||
|
||||
uint referenceId = ReadUInt32BE(reader);
|
||||
uint timescale = ReadUInt32BE(reader);
|
||||
|
||||
ulong earliestPresentationTime;
|
||||
ulong firstOffset;
|
||||
|
||||
if (version == 0){
|
||||
earliestPresentationTime = ReadUInt32BE(reader);
|
||||
firstOffset = ReadUInt32BE(reader);
|
||||
} else{
|
||||
earliestPresentationTime = ReadUInt64BE(reader);
|
||||
firstOffset = ReadUInt64BE(reader);
|
||||
}
|
||||
|
||||
reader.ReadUInt16();
|
||||
ushort referenceCount = ReadUInt16BE(reader);
|
||||
|
||||
long sidxEnd = sidxOffset + data.Length;
|
||||
long offset = sidxEnd + (long)firstOffset;
|
||||
|
||||
var references = new List<SidxReference>();
|
||||
|
||||
for (int i = 0; i < referenceCount; i++){
|
||||
uint refInfo = ReadUInt32BE(reader);
|
||||
|
||||
bool referenceType = (refInfo & 0x80000000) != 0;
|
||||
uint referenceSize = refInfo & 0x7FFFFFFF;
|
||||
|
||||
uint subsegmentDuration = ReadUInt32BE(reader);
|
||||
uint sap = ReadUInt32BE(reader);
|
||||
|
||||
references.Add(new SidxReference{
|
||||
Size = referenceSize,
|
||||
Duration = subsegmentDuration,
|
||||
Offset = offset,
|
||||
PresentationTime = (long)earliestPresentationTime
|
||||
});
|
||||
|
||||
offset += referenceSize;
|
||||
earliestPresentationTime += subsegmentDuration;
|
||||
}
|
||||
|
||||
return new SidxInfo{
|
||||
Timescale = timescale,
|
||||
References = references
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<byte[]> DownloadSidxAsync(
|
||||
HttpClient httpClient,
|
||||
string url,
|
||||
long start,
|
||||
long end){
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(start, end);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync();
|
||||
}
|
||||
|
||||
public class SidxInfo{
|
||||
public uint Timescale{ get; set; }
|
||||
public List<SidxReference> References{ get; set; }
|
||||
}
|
||||
|
||||
public class SidxReference{
|
||||
public long Size{ get; set; }
|
||||
public long Duration{ get; set; }
|
||||
public long Offset{ get; set; }
|
||||
public long PresentationTime{ get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -20,28 +20,39 @@ public class InheritAttributes{
|
|||
var keySystemInfo = new ExpandoObject() as IDictionary<string, object>;
|
||||
|
||||
foreach (var node in contentProtectionNodes){
|
||||
dynamic attributes = ParseAttribute.ParseAttributes(node); // Assume this returns a dictionary
|
||||
var testAttributes = attributes as IDictionary<string, object>;
|
||||
dynamic attributes = ParseAttribute.ParseAttributes(node);
|
||||
var attributesDict = attributes as IDictionary<string, object>;
|
||||
|
||||
if (attributesDict == null)
|
||||
continue;
|
||||
|
||||
if (!attributesDict.TryGetValue("schemeIdUri", out var schemeObj))
|
||||
continue;
|
||||
|
||||
var schemeIdUri = schemeObj?.ToString()?.ToLower();
|
||||
|
||||
if (schemeIdUri == null)
|
||||
continue;
|
||||
|
||||
attributesDict["schemeIdUri"] = schemeIdUri;
|
||||
|
||||
if (!KeySystemsMap.TryGetValue(schemeIdUri, out var keySystem))
|
||||
continue;
|
||||
|
||||
if (testAttributes != null && testAttributes.TryGetValue("schemeIdUri", out var attribute)){
|
||||
string? schemeIdUri = attribute.ToString()?.ToLower();
|
||||
if (schemeIdUri != null && KeySystemsMap.TryGetValue(schemeIdUri, out var keySystem)){
|
||||
dynamic info = new ExpandoObject();
|
||||
info.attributes = attributes;
|
||||
|
||||
var psshNode = XMLUtils.FindChildren(node, "cenc:pssh").FirstOrDefault();
|
||||
|
||||
if (psshNode != null){
|
||||
string pssh = psshNode.InnerText; // Assume this returns the inner text/content
|
||||
if (!string.IsNullOrEmpty(pssh)){
|
||||
info.pssh = DecodeB64ToUint8Array(pssh); // Convert base64 string to byte array
|
||||
}
|
||||
string pssh = psshNode.InnerText;
|
||||
|
||||
if (!string.IsNullOrEmpty(pssh))
|
||||
info.pssh = DecodeB64ToUint8Array(pssh);
|
||||
}
|
||||
|
||||
// Instead of using a dictionary key, add the key system directly as a member of the ExpandoObject
|
||||
keySystemInfo[keySystem] = info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keySystemInfo;
|
||||
}
|
||||
|
|
@ -82,8 +93,6 @@ public class InheritAttributes{
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static double? GetPeriodStart(dynamic attributes, dynamic? priorPeriodAttributes, string mpdType){
|
||||
// Summary of period start time calculation from DASH spec section 5.3.2.1
|
||||
//
|
||||
|
|
@ -179,14 +188,14 @@ public class InheritAttributes{
|
|||
var segmentInitializationParentNode = segmentList ?? segmentBase ?? segmentTemplate;
|
||||
var segmentInitialization = segmentInitializationParentNode != null ? XMLUtils.FindChildren(segmentInitializationParentNode, "Initialization").FirstOrDefault() : null;
|
||||
|
||||
dynamic template = segmentTemplate != null ? ParseAttribute.ParseAttributes(segmentTemplate) : null;
|
||||
dynamic? template = segmentTemplate != null ? ParseAttribute.ParseAttributes(segmentTemplate) : null;
|
||||
|
||||
if (template != null && segmentInitialization != null){
|
||||
template.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
|
||||
} else if (template != null && template.initialization != null){
|
||||
template?.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
|
||||
} else if (template != null && template?.initialization != null){
|
||||
dynamic init = new ExpandoObject();
|
||||
init.sourceURL = template.initialization;
|
||||
template.initialization = init;
|
||||
init.sourceURL = template?.initialization;
|
||||
template?.initialization = init;
|
||||
}
|
||||
|
||||
segmentInfo.template = template;
|
||||
|
|
@ -194,10 +203,14 @@ public class InheritAttributes{
|
|||
segmentInfo.list = segmentList != null
|
||||
? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, initialization = ParseAttribute.ParseAttributes(segmentInitialization) })
|
||||
: null;
|
||||
segmentInfo.baseInfo = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase), new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) : null;
|
||||
|
||||
dynamic initBas = new ExpandoObject();
|
||||
initBas.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
|
||||
//new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }
|
||||
segmentInfo.@base = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase),initBas ) : null;
|
||||
|
||||
// Clean up null entries
|
||||
var dict = (IDictionary<string, object>)segmentInfo;
|
||||
var dict = (IDictionary<string, object?>)segmentInfo;
|
||||
var keys = dict.Keys.ToList();
|
||||
foreach (var key in keys){
|
||||
if (dict[key] == null){
|
||||
|
|
@ -301,24 +314,25 @@ public class InheritAttributes{
|
|||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes);
|
||||
|
||||
var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault();
|
||||
var captionServices = ParseCaptionServiceMetadata(ParseAttribute.ParseAttributes(accessibility));
|
||||
var captionServices = ParseCaptionServiceMetadata(accessibility != null ? ParseAttribute.ParseAttributes(accessibility) : null);
|
||||
|
||||
if (captionServices != null){
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices });
|
||||
}
|
||||
|
||||
XmlElement label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
|
||||
if (label != null && label.ChildNodes.Count > 0){
|
||||
var labelVal = label.ChildNodes[0].ToString().Trim();
|
||||
XmlElement? label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
|
||||
if (label is{ ChildNodes.Count: > 0 }){
|
||||
var labelVal = label.ChildNodes[0]?.Value?.Trim();
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal });
|
||||
}
|
||||
|
||||
var contentProtection = GenerateKeySystemInformation(XMLUtils.FindChildren(adaptationSet, "ContentProtection"));
|
||||
var nodes = XMLUtils.FindChildren(adaptationSet, "ContentProtection");
|
||||
var contentProtection = GenerateKeySystemInformation(nodes);
|
||||
var tempTestContentProtection = contentProtection as IDictionary<string, Object>;
|
||||
if (tempTestContentProtection != null && tempTestContentProtection.Count > 0){
|
||||
if (tempTestContentProtection is{ Count: > 0 }){
|
||||
dynamic contentProt = new ExpandoObject();
|
||||
contentProt.contentProtection = contentProtection;
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt );
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt);
|
||||
}
|
||||
|
||||
var segmentInfo = GetSegmentInformation(adaptationSet);
|
||||
|
|
@ -337,10 +351,22 @@ public class InheritAttributes{
|
|||
return list;
|
||||
}
|
||||
|
||||
|
||||
public static List<dynamic> InheritBaseUrls(dynamic adaptationSetAttributes, dynamic adaptationSetBaseUrls, dynamic adaptationSetSegmentInfo, XmlElement representation){
|
||||
var repBaseUrlElements = XMLUtils.FindChildren(representation, "BaseURL");
|
||||
List<dynamic> repBaseUrls = BuildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements);
|
||||
var attributes = ObjectUtilities.MergeExpandoObjects(adaptationSetAttributes, ParseAttribute.ParseAttributes(representation));
|
||||
|
||||
// Representation ContentProtection
|
||||
var repProtectionNodes = XMLUtils.FindChildren(representation, "ContentProtection");
|
||||
var repContentProtection = GenerateKeySystemInformation(repProtectionNodes);
|
||||
|
||||
if ((repContentProtection as IDictionary<string, object>)?.Count > 0){
|
||||
dynamic contentProt = new ExpandoObject();
|
||||
contentProt.contentProtection = repContentProtection;
|
||||
attributes = ObjectUtilities.MergeExpandoObjects(attributes, contentProt);
|
||||
}
|
||||
|
||||
var representationSegmentInfo = GetSegmentInformation(representation);
|
||||
|
||||
return repBaseUrls.Select(baseUrl => {
|
||||
|
|
|
|||
|
|
@ -80,12 +80,12 @@ public class ParseAttribute{
|
|||
// });
|
||||
// }
|
||||
|
||||
public static dynamic ParseAttributes(XmlNode el){
|
||||
public static dynamic ParseAttributes(XmlNode? el){
|
||||
var expandoObj = new ExpandoObject() as IDictionary<string, object>;
|
||||
|
||||
if (el != null && el.Attributes != null){
|
||||
if (el is{ Attributes: not null }){
|
||||
foreach (XmlAttribute attr in el.Attributes){
|
||||
Func<string, object> parseFn;
|
||||
Func<string, object>? parseFn;
|
||||
if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){
|
||||
expandoObj[attr.Name] = parseFn(attr.Value);
|
||||
} else{
|
||||
|
|
|
|||
|
|
@ -14,20 +14,18 @@ public class ToPlaylistsClass{
|
|||
|
||||
public static dynamic GenerateSegments(dynamic input){
|
||||
dynamic segmentAttributes = new ExpandoObject();
|
||||
Func<dynamic, List<dynamic>, List<dynamic>> segmentsFn = null;
|
||||
Func<dynamic, List<dynamic>, List<dynamic>>? segmentsFn = null;
|
||||
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(input.segmentInfo,"template") != null){
|
||||
segmentsFn = SegmentTemplate.SegmentsFromTemplate;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.template);
|
||||
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"@base") != null){
|
||||
//TODO
|
||||
Console.WriteLine("UNTESTED PARSING");
|
||||
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"base") != null){
|
||||
segmentsFn = SegmentBase.SegmentsFromBase;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.@base);
|
||||
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"list") != null){
|
||||
//TODO
|
||||
Console.WriteLine("UNTESTED PARSING");
|
||||
Console.Error.WriteLine("UNTESTED PARSING");
|
||||
segmentsFn = SegmentList.SegmentsFromList;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.list);
|
||||
}
|
||||
|
|
@ -39,7 +37,7 @@ public class ToPlaylistsClass{
|
|||
return segmentsInfo;
|
||||
}
|
||||
|
||||
List<dynamic> segments = segmentsFn(segmentAttributes, input.segmentInfo.segmentTimeline);
|
||||
List<dynamic> segments = segmentsFn(segmentAttributes, ObjectUtilities.GetMemberValue(input.segmentInfo, "segmentTimeline"));
|
||||
|
||||
// Duration processing
|
||||
if (ObjectUtilities.GetMemberValue(segmentAttributes,"duration") != null){
|
||||
|
|
|
|||
|
|
@ -3,32 +3,34 @@ using System.Collections.Generic;
|
|||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class SegmentBase{
|
||||
public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic> segmentTimeline){
|
||||
if (attributes.baseUrl == null){
|
||||
public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic>? segmentTimeline = null){
|
||||
var baseUrl = ObjectUtilities.GetMemberValue(attributes, "baseUrl");
|
||||
var initialization = ObjectUtilities.GetMemberValue(attributes, "initialization") ?? new ExpandoObject();
|
||||
var sourceDuration = ObjectUtilities.GetMemberValue(attributes, "sourceDuration");
|
||||
var indexRange = ObjectUtilities.GetMemberValue(attributes, "indexRange") ?? "";
|
||||
var periodStart = ObjectUtilities.GetMemberValue(attributes, "periodStart");
|
||||
var presentationTime = ObjectUtilities.GetMemberValue(attributes, "presentationTime");
|
||||
var number = ObjectUtilities.GetMemberValue(attributes, "number") ?? 0;
|
||||
var duration = ObjectUtilities.GetMemberValue(attributes, "duration");
|
||||
|
||||
if (baseUrl == null){
|
||||
throw new Exception("NO_BASE_URL");
|
||||
}
|
||||
|
||||
var initialization = attributes.initialization ?? new ExpandoObject();
|
||||
var sourceDuration = attributes.sourceDuration;
|
||||
var indexRange = attributes.indexRange ?? "";
|
||||
var periodStart = attributes.periodStart;
|
||||
var presentationTime = attributes.presentationTime;
|
||||
var number = attributes.number ?? 0;
|
||||
var duration = attributes.duration;
|
||||
|
||||
dynamic initSegment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = initialization.sourceURL,
|
||||
range = initialization.range
|
||||
baseUrl = baseUrl,
|
||||
source = ObjectUtilities.GetMemberValue(initialization, "sourceURL"),
|
||||
range = ObjectUtilities.GetMemberValue(initialization, "range")
|
||||
});
|
||||
|
||||
dynamic segment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = attributes.baseUrl,
|
||||
baseUrl = baseUrl,
|
||||
source = baseUrl,
|
||||
indexRange = indexRange
|
||||
});
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ public class SegmentBase{
|
|||
|
||||
if (duration != null){
|
||||
var segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes);
|
||||
|
||||
if (segmentTimeInfo.Count > 0){
|
||||
segment.duration = segmentTimeInfo[0].duration;
|
||||
segment.timeline = segmentTimeInfo[0].timeline;
|
||||
|
|
@ -46,6 +49,7 @@ public class SegmentBase{
|
|||
}
|
||||
|
||||
segment.presentationTime = presentationTime ?? periodStart;
|
||||
|
||||
segment.number = number;
|
||||
|
||||
return new List<dynamic>{ segment };
|
||||
|
|
|
|||
|
|
@ -1,23 +1,34 @@
|
|||
using System;
|
||||
using System.Dynamic;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class UrlType{
|
||||
public static dynamic UrlTypeToSegment(dynamic input){
|
||||
dynamic segment = new {
|
||||
uri = ObjectUtilities.GetMemberValue(input,"source"),
|
||||
resolvedUri = new Uri(new Uri(input.baseUrl, UriKind.Absolute), input.source).ToString()
|
||||
};
|
||||
string baseUrl = Convert.ToString(ObjectUtilities.GetMemberValue(input, "baseUrl"));
|
||||
string source = Convert.ToString(ObjectUtilities.GetMemberValue(input, "source"));
|
||||
|
||||
var baseUri = new Uri(baseUrl, UriKind.Absolute);
|
||||
|
||||
dynamic segment = new ExpandoObject();
|
||||
segment.uri = source;
|
||||
segment.resolvedUri = new Uri(baseUri, source).ToString();
|
||||
|
||||
string rangeStr = Convert.ToString(
|
||||
!string.IsNullOrEmpty(Convert.ToString(ObjectUtilities.GetMemberValue(input, "range")))
|
||||
? ObjectUtilities.GetMemberValue(input, "range")
|
||||
: ObjectUtilities.GetMemberValue(input, "indexRange")
|
||||
);
|
||||
|
||||
string rangeStr = !string.IsNullOrEmpty(input.range) ? ObjectUtilities.GetMemberValue(input,"range") : ObjectUtilities.GetMemberValue(input,"indexRange");
|
||||
if (!string.IsNullOrEmpty(rangeStr)){
|
||||
var ranges = rangeStr.Split('-');
|
||||
|
||||
long startRange = long.Parse(ranges[0]);
|
||||
long endRange = long.Parse(ranges[1]);
|
||||
long length = endRange - startRange + 1;
|
||||
|
||||
segment.ByteRange = new {
|
||||
segment.ByteRange = new{
|
||||
length = length,
|
||||
offset = startRange
|
||||
};
|
||||
|
|
|
|||
|
|
@ -83,12 +83,12 @@ public class ObjectUtilities{
|
|||
targetDict[fieldToSet] = valueToSet;
|
||||
}
|
||||
|
||||
public static object GetMemberValue(dynamic obj, string memberName){
|
||||
public static object? GetMemberValue(dynamic obj, string memberName){
|
||||
// First, check if the object is indeed an ExpandoObject
|
||||
if (obj is ExpandoObject expando){
|
||||
// Try to get the value from the ExpandoObject
|
||||
var dictionary = (IDictionary<string, object>)expando;
|
||||
if (dictionary.TryGetValue(memberName, out object value)){
|
||||
var dictionary = (IDictionary<string, object?>)expando;
|
||||
if (dictionary.TryGetValue(memberName, out object? value)){
|
||||
// Return the found value, which could be null
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ using System.Xml;
|
|||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class XMLUtils{
|
||||
public static List<XmlElement> FindChildren(XmlElement element, string name){
|
||||
return From(element.ChildNodes).OfType<XmlElement>().Where(child => child.Name == name).ToList();
|
||||
public static List<XmlElement> FindChildren(XmlElement parent, string name){
|
||||
return From(parent.ChildNodes).OfType<XmlElement>().Where(child => child.Name == name).ToList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static string GetContent(XmlElement element){
|
||||
return element.InnerText.Trim();
|
||||
}
|
||||
|
|
|
|||
83
CRD/Utils/PeriodicWorkRunner.cs
Normal file
83
CRD/Utils/PeriodicWorkRunner.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposable{
|
||||
private CancellationTokenSource? cts;
|
||||
private Task? loopTask;
|
||||
|
||||
private TimeSpan currentInterval;
|
||||
|
||||
public DateTime LastRunTime = DateTime.MinValue;
|
||||
|
||||
public void StartOrRestart(TimeSpan interval, bool runImmediately = false, bool force = false){
|
||||
if (interval <= TimeSpan.Zero){
|
||||
Stop();
|
||||
currentInterval = Timeout.InfiniteTimeSpan;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && interval == currentInterval){
|
||||
return;
|
||||
}
|
||||
|
||||
currentInterval = interval;
|
||||
|
||||
Stop();
|
||||
|
||||
cts = new CancellationTokenSource();
|
||||
loopTask = RunLoopAsync(interval, runImmediately, cts.Token);
|
||||
}
|
||||
|
||||
public void StartOrRestartMinutes(int minutes, bool runImmediately = false, bool force = false)
|
||||
=> StartOrRestart(TimeSpan.FromMinutes(minutes), runImmediately);
|
||||
|
||||
public void Stop(){
|
||||
if (cts is null) return;
|
||||
|
||||
try{
|
||||
cts.Cancel();
|
||||
} finally{
|
||||
cts.Dispose();
|
||||
cts = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(TimeSpan interval, bool runImmediately, CancellationToken token){
|
||||
if (runImmediately){
|
||||
await SafeRunWork(token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(interval);
|
||||
|
||||
try{
|
||||
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)){
|
||||
await SafeRunWork(token).ConfigureAwait(false);
|
||||
}
|
||||
} catch (OperationCanceledException){
|
||||
}
|
||||
}
|
||||
|
||||
private int running;
|
||||
|
||||
private async Task SafeRunWork(CancellationToken token){
|
||||
if (Interlocked.Exchange(ref running, 1) == 1){
|
||||
Console.Error.WriteLine("Task is already running!");
|
||||
return;
|
||||
}
|
||||
|
||||
try{
|
||||
await work(token).ConfigureAwait(false);
|
||||
LastRunTime = DateTime.Now;
|
||||
} catch (OperationCanceledException) when (token.IsCancellationRequested){
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine(ex);
|
||||
} finally{
|
||||
Interlocked.Exchange(ref running, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
CRD/Utils/QueueManagement/QueuePersistenceManager.cs
Normal file
117
CRD/Utils/QueueManagement/QueuePersistenceManager.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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.Queue;
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Sonarr.Models;
|
||||
|
|
@ -13,6 +14,15 @@ public class SonarrEpisode{
|
|||
[JsonProperty("seriesId")]
|
||||
public int SeriesId{ get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the images.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The images.
|
||||
/// </value>
|
||||
[JsonProperty("images")]
|
||||
public List<SonarrImage>? Images{ get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the episode file identifier.
|
||||
/// </summary>
|
||||
|
|
@ -138,4 +148,8 @@ public class SonarrEpisode{
|
|||
/// </value>
|
||||
[JsonProperty("id")]
|
||||
public int Id{ get; set; }
|
||||
|
||||
[JsonProperty("series")]
|
||||
public SonarrSeries? Series{ get; set; }
|
||||
|
||||
}
|
||||
|
|
@ -123,6 +123,21 @@ public class SonarrClient{
|
|||
return series;
|
||||
}
|
||||
|
||||
public async Task<SonarrSeries?> GetSeries(int seriesId){
|
||||
var json = await GetJson($"/v3/series/{seriesId}{(true ? $"?includeSeasonImages={true}" : "")}");
|
||||
|
||||
SonarrSeries? series = null;
|
||||
|
||||
try{
|
||||
series = Helpers.Deserialize<SonarrSeries>(json, null);
|
||||
} catch (Exception e){
|
||||
MainWindow.Instance.ShowError("Sonarr GetSeries error \n" + e);
|
||||
Console.Error.WriteLine("[Sonarr] Sonarr GetSeries error \n" + e);
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
public async Task<List<SonarrEpisode>> GetEpisodes(int seriesId){
|
||||
var json = await GetJson($"/v3/episode?seriesId={seriesId}");
|
||||
|
||||
|
|
@ -139,11 +154,11 @@ public class SonarrClient{
|
|||
}
|
||||
|
||||
|
||||
public async Task<SonarrEpisode> GetEpisode(int episodeId){
|
||||
var json = await GetJson($"/v3/episode/id={episodeId}");
|
||||
var episode = new SonarrEpisode();
|
||||
public async Task<SonarrEpisode?> GetEpisode(int episodeId){
|
||||
var json = await GetJson($"/v3/episode/{episodeId}");
|
||||
SonarrEpisode? episode = null;
|
||||
try{
|
||||
episode = Helpers.Deserialize<SonarrEpisode>(json,null) ?? new SonarrEpisode();
|
||||
episode = Helpers.Deserialize<SonarrEpisode>(json,null);
|
||||
} catch (Exception e){
|
||||
MainWindow.Instance.ShowError("Sonarr GetEpisode error \n" + e);
|
||||
Console.Error.WriteLine("Sonarr GetEpisode error \n" + e);
|
||||
|
|
|
|||
|
|
@ -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,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.ViewModels;
|
||||
using Newtonsoft.Json;
|
||||
|
|
@ -8,6 +9,9 @@ namespace CRD.Utils.Structs.Crunchyroll;
|
|||
public class CrDownloadOptions{
|
||||
#region General Settings
|
||||
|
||||
[JsonProperty("gh_update_prereleases")]
|
||||
public bool GhUpdatePrereleases{ get; set; }
|
||||
|
||||
[JsonProperty("shutdown_when_queue_empty")]
|
||||
public bool ShutdownWhenQueueEmpty{ get; set; }
|
||||
|
||||
|
|
@ -17,6 +21,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; }
|
||||
|
||||
|
|
@ -56,6 +63,14 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("download_finished_sound_path")]
|
||||
public string? DownloadFinishedSoundPath{ get; set; }
|
||||
|
||||
[JsonProperty("download_finished_execute")]
|
||||
public bool DownloadFinishedExecute{ get; set; }
|
||||
|
||||
[JsonProperty("download_finished_execute_path")]
|
||||
public string? DownloadFinishedExecutePath{ get; set; }
|
||||
|
||||
[JsonProperty("download_only_with_all_selected_dubsub")]
|
||||
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
|
||||
|
||||
[JsonProperty("background_image_opacity")]
|
||||
public double BackgroundImageOpacity{ get; set; }
|
||||
|
|
@ -81,6 +96,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; }
|
||||
|
||||
|
|
@ -93,6 +111,13 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("history_count_sonarr")]
|
||||
public bool HistoryCountSonarr{ get; set; }
|
||||
|
||||
[JsonProperty("history_auto_refresh_interval_minutes")]
|
||||
public int HistoryAutoRefreshIntervalMinutes{ get; set; }
|
||||
|
||||
[JsonProperty("history_auto_refresh_mode")]
|
||||
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
|
||||
|
||||
|
||||
[JsonProperty("sonarr_properties")]
|
||||
public SonarrProperties? SonarrProperties{ get; set; }
|
||||
|
||||
|
|
@ -141,6 +166,20 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("flare_solverr_properties")]
|
||||
public FlareSolverrProperties? FlareSolverrProperties{ get; set; }
|
||||
|
||||
[JsonProperty("flare_solverr_mitm_properties")]
|
||||
public MitmProxyProperties? FlareSolverrMitmProperties{ get; set; }
|
||||
|
||||
[JsonProperty("tray_icon_enabled")]
|
||||
public bool TrayIconEnabled{ get; set; }
|
||||
|
||||
[JsonProperty("tray_start_minimized")]
|
||||
public bool StartMinimizedToTray{ get; set; }
|
||||
|
||||
[JsonProperty("tray_on_minimize")]
|
||||
public bool MinimizeToTray{ get; set; }
|
||||
|
||||
[JsonProperty("tray_on_close")]
|
||||
public bool MinimizeToTrayOnClose{ get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
|
|
@ -236,6 +275,9 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("mux_fonts")]
|
||||
public bool MuxFonts{ get; set; }
|
||||
|
||||
[JsonProperty("mux_typesetting_fonts")]
|
||||
public bool MuxTypesettingFonts{ get; set; }
|
||||
|
||||
[JsonProperty("mux_cover")]
|
||||
public bool MuxCover{ get; set; }
|
||||
|
||||
|
|
@ -281,6 +323,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; }
|
||||
|
||||
|
|
@ -311,6 +356,9 @@ public class CrDownloadOptions{
|
|||
[JsonProperty("calendar_show_upcoming_episodes")]
|
||||
public bool CalendarShowUpcomingEpisodes{ get; set; }
|
||||
|
||||
[JsonProperty("calendar_update_history")]
|
||||
public bool UpdateHistoryFromCalendar{ get; set; }
|
||||
|
||||
[JsonProperty("stream_endpoint_settings")]
|
||||
public CrAuthSettings? StreamEndpoint{ get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,20 @@ using Newtonsoft.Json;
|
|||
|
||||
namespace CRD.Utils.Structs.Crunchyroll;
|
||||
|
||||
public class CrMultiProfile{
|
||||
|
||||
[JsonProperty("tier_max_profiles")]
|
||||
public int? TierMaxProfiles{ get; set; }
|
||||
|
||||
[JsonProperty("max_profiles")]
|
||||
public int? MaxProfiles{ get; set; }
|
||||
|
||||
[JsonProperty("profiles")]
|
||||
public List<CrProfile> Profiles{ get; set; } = [];
|
||||
}
|
||||
|
||||
public class CrProfile{
|
||||
|
||||
public string? Avatar{ get; set; }
|
||||
public string? Email{ get; set; }
|
||||
public string? Username{ get; set; }
|
||||
|
|
@ -20,8 +33,14 @@ public class CrProfile{
|
|||
[JsonProperty("preferred_content_subtitle_language")]
|
||||
public string? PreferredContentSubtitleLanguage{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Subscription? Subscription{ get; set; }
|
||||
[JsonProperty("can_switch")]
|
||||
public bool CanSwitch{ get; set; }
|
||||
|
||||
[JsonProperty("is_selected")]
|
||||
public bool IsSelected{ get; set; }
|
||||
|
||||
[JsonProperty("is_pin_protected")]
|
||||
public bool IsPinProtected{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasPremium{ get; set; }
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ public class CrBrowseEpisodeVersion{
|
|||
public Locale? AudioLocale{ get; set; }
|
||||
|
||||
public string? Guid{ get; set; }
|
||||
public bool? Original{ get; set; }
|
||||
public bool Original{ get; set; }
|
||||
public string? Variant{ get; set; }
|
||||
|
||||
[JsonProperty("season_guid")]
|
||||
|
|
@ -200,6 +200,6 @@ public class CrBrowseEpisodeVersion{
|
|||
public string? MediaGuid{ get; set; }
|
||||
|
||||
[JsonProperty("is_premium_only")]
|
||||
public bool? IsPremiumOnly{ get; set; }
|
||||
public bool IsPremiumOnly{ get; set; }
|
||||
|
||||
}
|
||||
|
|
@ -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(){
|
||||
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(){
|
||||
|
|
@ -366,13 +385,13 @@ public class CrunchyEpMeta{
|
|||
public string? EpisodeNumber{ get; set; }
|
||||
public string? EpisodeTitle{ get; set; }
|
||||
public string? Description{ get; set; }
|
||||
public string? EpisodeId{ get; set; }
|
||||
public string? SeasonId{ get; set; }
|
||||
public string? Season{ get; set; }
|
||||
public string? SeriesId{ get; set; }
|
||||
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; }
|
||||
|
|
@ -384,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; }
|
||||
|
|
@ -398,18 +418,53 @@ 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 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 void ResetForRetry(){
|
||||
State = DownloadState.Queued;
|
||||
ResumeState = DownloadState.Downloading;
|
||||
Percent = 0;
|
||||
Time = 0;
|
||||
DownloadSpeedBytes = 0;
|
||||
Doing = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public class CrunchyEpMetaData{
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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 ();
|
||||
|
|
@ -23,6 +23,10 @@ public class StreamError{
|
|||
public bool IsTooManyActiveStreamsError(){
|
||||
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
|
||||
}
|
||||
|
||||
public bool IsRateLimitError(){
|
||||
return Error?.Contains("4294") == true;
|
||||
}
|
||||
}
|
||||
|
||||
public class ActiveStream{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Views;
|
||||
|
|
@ -12,6 +14,23 @@ public class AuthData{
|
|||
public string Password{ get; set; }
|
||||
}
|
||||
|
||||
public partial class AccountProfile : ObservableObject{
|
||||
[ObservableProperty]
|
||||
private string _profileName = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _avatarUrl = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private Bitmap? _profileImage;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _canBeSelected;
|
||||
|
||||
public string? ProfileId{ get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class CrAuthSettings{
|
||||
public string Endpoint{ get; set; }
|
||||
public string Authorization{ get; set; }
|
||||
|
|
@ -21,6 +40,8 @@ public class CrAuthSettings{
|
|||
|
||||
public bool Video{ get; set; }
|
||||
public bool Audio{ get; set; }
|
||||
|
||||
public bool UseDefault{ get; set; } = true;
|
||||
}
|
||||
|
||||
public class StreamInfo{
|
||||
|
|
@ -51,9 +72,18 @@ public class LanguageItem{
|
|||
public string Language{ get; set; }
|
||||
}
|
||||
|
||||
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
|
||||
|
||||
public class EpisodeAndLanguage{
|
||||
public List<CrunchyEpisode> Items{ get; set; }
|
||||
public List<LanguageItem> Langs{ get; set; }
|
||||
public List<EpisodeVariant> Variants{ get; set; } = new();
|
||||
|
||||
public bool AddUnique(CrunchyEpisode item, LanguageItem lang){
|
||||
if (Variants.Any(v => v.Lang.CrLocale == lang.CrLocale))
|
||||
return false;
|
||||
|
||||
Variants.Add(new EpisodeVariant(item, lang));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
|
||||
|
|
@ -103,6 +133,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; }
|
||||
|
||||
|
|
@ -131,6 +162,11 @@ public class StringItemWithDisplayName{
|
|||
public string value{ get; set; }
|
||||
}
|
||||
|
||||
public class RefreshModeOption{
|
||||
public string DisplayName{ get; set; }
|
||||
public HistoryRefreshMode value{ get; set; }
|
||||
}
|
||||
|
||||
public class WindowSettings{
|
||||
public double Width{ get; set; }
|
||||
public double Height{ get; set; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CRD.Downloader;
|
||||
|
|
@ -42,6 +43,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; }
|
||||
|
||||
|
|
@ -85,7 +89,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))
|
||||
|
|
@ -140,21 +144,27 @@ public class HistoryEpisode : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
public async Task DownloadEpisodeDefault(){
|
||||
await DownloadEpisode();
|
||||
await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
|
||||
}
|
||||
|
||||
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode, string overrideDownloadPath,bool chekQueueForId){
|
||||
|
||||
if (chekQueueForId && QueueManager.Instance.Queue.Any(item => item.Data.Any(epmeta => epmeta.MediaId == EpisodeId))){
|
||||
Console.Error.WriteLine($"Episode already in queue! E{EpisodeSeasonNum}-{EpisodeTitle}");
|
||||
return;
|
||||
}
|
||||
|
||||
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public class HistorySeason : INotifyPropertyChanged{
|
|||
public string? SeasonNum{ get; set; }
|
||||
|
||||
[JsonProperty("season_special_season")]
|
||||
public bool? SpecialSeason{ get; set; }
|
||||
public bool SpecialSeason{ get; set; }
|
||||
|
||||
[JsonProperty("season_downloaded_episodes")]
|
||||
public int DownloadedEpisodes{ get; set; }
|
||||
|
|
@ -40,7 +40,7 @@ public class HistorySeason : INotifyPropertyChanged{
|
|||
public ObservableCollection<string> HistorySeasonDubLangOverride{ get; set; } =[];
|
||||
|
||||
[JsonIgnore]
|
||||
public string CombinedProperty => SpecialSeason ?? false ? $"Specials {SeasonNum}" : $"Season {SeasonNum}";
|
||||
public string CombinedProperty => SpecialSeason ? $"Specials {SeasonNum}" : $"Season {SeasonNum}";
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsExpanded{ get; set; }
|
||||
|
|
@ -53,7 +53,7 @@ public class HistorySeason : INotifyPropertyChanged{
|
|||
public StringItem? _selectedVideoQualityItem;
|
||||
|
||||
[JsonIgnore]
|
||||
private bool Loading = false;
|
||||
private bool Loading;
|
||||
|
||||
[JsonIgnore]
|
||||
public StringItem? SelectedVideoQualityItem{
|
||||
|
|
|
|||
|
|
@ -64,16 +64,16 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
public string HistorySeriesVideoQualityOverride{ get; set; } = "";
|
||||
|
||||
[JsonProperty("history_series_available_soft_subs")]
|
||||
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } =[];
|
||||
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } = [];
|
||||
|
||||
[JsonProperty("history_series_available_dub_lang")]
|
||||
public List<string> HistorySeriesAvailableDubLang{ get; set; } =[];
|
||||
public List<string> HistorySeriesAvailableDubLang{ get; set; } = [];
|
||||
|
||||
[JsonProperty("history_series_soft_subs_override")]
|
||||
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } =[];
|
||||
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } = [];
|
||||
|
||||
[JsonProperty("history_series_dub_lang_override")]
|
||||
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } =[];
|
||||
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } = [];
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -246,8 +246,19 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
public void UpdateNewEpisodes(){
|
||||
int count = 0;
|
||||
NewEpisodes = EnumerateEpisodes().Count();
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
|
||||
}
|
||||
|
||||
public async Task AddNewMissingToDownloads(bool checkQueueForId = false){
|
||||
var episodes = EnumerateEpisodes().ToList();
|
||||
episodes.Reverse();
|
||||
foreach (var ep in episodes){
|
||||
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", checkQueueForId);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<HistoryEpisode> EnumerateEpisodes(){
|
||||
bool foundWatched = false;
|
||||
var options = CrunchyrollManager.Instance.CrunOptions;
|
||||
|
||||
|
|
@ -257,64 +268,56 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
!string.IsNullOrEmpty(SonarrSeriesId);
|
||||
bool skipUnmonitored = options.HistorySkipUnmonitored;
|
||||
bool countMissing = options.HistoryCountMissing;
|
||||
bool useSonarrCounting = options.HistoryCountSonarr;
|
||||
bool useSonarr = sonarrEnabled && options.HistoryCountSonarr;
|
||||
|
||||
for (int i = Seasons.Count - 1; i >= 0; i--){
|
||||
var season = Seasons[i];
|
||||
var episodes = season.EpisodesList;
|
||||
|
||||
if (season.SpecialSeason == true){
|
||||
if (season.SpecialSeason){
|
||||
if (historyAddSpecials){
|
||||
for (int j = episodes.Count - 1; j >= 0; j--){
|
||||
var ep = episodes[j];
|
||||
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
|
||||
count++;
|
||||
}
|
||||
if (ShouldCountEpisode(ep, useSonarr, countMissing, false))
|
||||
yield return ep;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
for (int j = episodes.Count - 1; j >= 0; j--){
|
||||
var ep = episodes[j];
|
||||
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ep.SpecialEpisode){
|
||||
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
|
||||
count++;
|
||||
if (historyAddSpecials &&
|
||||
ShouldCountEpisode(ep, useSonarr, countMissing, false)){
|
||||
yield return ep;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){
|
||||
count++;
|
||||
if (ShouldCountEpisode(ep, useSonarr, countMissing, foundWatched)){
|
||||
yield return ep;
|
||||
} else{
|
||||
foundWatched = true;
|
||||
//if not count specials break
|
||||
if (!historyAddSpecials && !countMissing){
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWatched && !historyAddSpecials && !countMissing){
|
||||
if (!historyAddSpecials && !countMissing)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NewEpisodes = count;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
|
||||
if (foundWatched && !historyAddSpecials && !countMissing)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldCountEpisode(HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){
|
||||
|
|
@ -329,71 +332,6 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
|
||||
}
|
||||
|
||||
public async Task AddNewMissingToDownloads(){
|
||||
bool foundWatched = false;
|
||||
var options = CrunchyrollManager.Instance.CrunOptions;
|
||||
|
||||
bool historyAddSpecials = options.HistoryAddSpecials;
|
||||
bool sonarrEnabled = SeriesType != SeriesType.Artist &&
|
||||
options.SonarrProperties?.SonarrEnabled == true &&
|
||||
!string.IsNullOrEmpty(SonarrSeriesId);
|
||||
bool skipUnmonitored = options.HistorySkipUnmonitored;
|
||||
bool countMissing = options.HistoryCountMissing;
|
||||
bool useSonarrCounting = options.HistoryCountSonarr;
|
||||
|
||||
for (int i = Seasons.Count - 1; i >= 0; i--){
|
||||
var season = Seasons[i];
|
||||
var episodes = season.EpisodesList;
|
||||
|
||||
if (season.SpecialSeason == true){
|
||||
if (historyAddSpecials){
|
||||
for (int j = episodes.Count - 1; j >= 0; j--){
|
||||
var ep = episodes[j];
|
||||
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
|
||||
await ep.DownloadEpisode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int j = episodes.Count - 1; j >= 0; j--){
|
||||
var ep = episodes[j];
|
||||
|
||||
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ep.SpecialEpisode){
|
||||
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
|
||||
await ep.DownloadEpisode();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){
|
||||
await ep.DownloadEpisode();
|
||||
} else{
|
||||
foundWatched = true;
|
||||
if (!historyAddSpecials && !countMissing){
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWatched && !historyAddSpecials && !countMissing){
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> FetchData(string? seasonId){
|
||||
Console.WriteLine($"Fetching Data for: {SeriesTitle}");
|
||||
FetchingData = true;
|
||||
|
|
@ -412,6 +350,7 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
break;
|
||||
case SeriesType.Movie:
|
||||
case SeriesType.Series:
|
||||
case SeriesType.Unknown:
|
||||
default:
|
||||
|
|
@ -458,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:
|
||||
|
|
@ -467,55 +407,62 @@ public class HistorySeries : INotifyPropertyChanged{
|
|||
}
|
||||
|
||||
public void UpdateSeriesFolderPath(){
|
||||
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
|
||||
// Reset state first
|
||||
SeriesFolderPath = string.Empty;
|
||||
SeriesFolderPathExists = false;
|
||||
|
||||
var season = Seasons.FirstOrDefault(s => !string.IsNullOrEmpty(s.SeasonDownloadPath));
|
||||
|
||||
// Series path
|
||||
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
|
||||
SeriesFolderPath = SeriesDownloadPath;
|
||||
SeriesFolderPathExists = true;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (season is{ SeasonDownloadPath: not null }){
|
||||
// Season path
|
||||
if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){
|
||||
try{
|
||||
var seasonPath = season.SeasonDownloadPath;
|
||||
var directoryInfo = new DirectoryInfo(seasonPath);
|
||||
var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
|
||||
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
|
||||
var parentFolder = directoryInfo.Parent?.FullName;
|
||||
|
||||
if (Directory.Exists(parentFolderPath)){
|
||||
SeriesFolderPath = parentFolderPath;
|
||||
if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
|
||||
SeriesFolderPath = parentFolder;
|
||||
SeriesFolderPathExists = true;
|
||||
}
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
return;
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
|
||||
Console.Error.WriteLine($"Error resolving season folder: {e.Message}");
|
||||
}
|
||||
}
|
||||
} else{
|
||||
string customPath;
|
||||
|
||||
if (string.IsNullOrEmpty(SeriesTitle))
|
||||
// Auto generated path
|
||||
if (string.IsNullOrEmpty(SeriesTitle)){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
return;
|
||||
}
|
||||
|
||||
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
|
||||
|
||||
if (string.IsNullOrEmpty(seriesTitle))
|
||||
if (string.IsNullOrEmpty(seriesTitle)){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
return;
|
||||
|
||||
// Check Crunchyroll download directory
|
||||
var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath;
|
||||
if (!string.IsNullOrEmpty(downloadDirPath)){
|
||||
customPath = Path.Combine(downloadDirPath, seriesTitle);
|
||||
} else{
|
||||
// Fallback to configured VIDEOS_DIR path
|
||||
customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle);
|
||||
}
|
||||
|
||||
// Check if custom path exists
|
||||
string basePath =
|
||||
!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath)
|
||||
? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath
|
||||
: CfgManager.PathVIDEOS_DIR;
|
||||
|
||||
var customPath = Path.Combine(basePath, seriesTitle);
|
||||
|
||||
if (Directory.Exists(customPath)){
|
||||
SeriesFolderPath = customPath;
|
||||
SeriesFolderPathExists = true;
|
||||
}
|
||||
}
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,8 +127,7 @@ public class EpisodeHighlightTextBlock : TextBlock{
|
|||
streamingService == StreamingService.Crunchyroll ? new HashSet<string>(CrunchyrollManager.Instance.CrunOptions.DlSubs) :
|
||||
new HashSet<string>();
|
||||
|
||||
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) &&
|
||||
subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []);
|
||||
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && (subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []) || subSet.Contains("all"));
|
||||
|
||||
if (higlight){
|
||||
Foreground = Brushes.Orange;
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ public class HighlightingTextBlock : TextBlock{
|
|||
foreach (var item in Items){
|
||||
var run = new Run(item);
|
||||
|
||||
if (highlightSet.Contains(item)){
|
||||
if (highlightSet.Contains(item) || highlightSet.Contains("all")){
|
||||
run.Foreground = Brushes.Orange;
|
||||
// run.FontWeight = FontWeight.Bold;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,51 +10,31 @@ using System.Runtime.CompilerServices;
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using Newtonsoft.Json;
|
||||
using NuGet.Versioning;
|
||||
|
||||
namespace CRD.Utils.Updater;
|
||||
|
||||
public class Updater : INotifyPropertyChanged{
|
||||
public double progress = 0;
|
||||
public bool failed = false;
|
||||
public class Updater : ObservableObject{
|
||||
public double Progress;
|
||||
public bool Failed;
|
||||
public string LatestVersion = "";
|
||||
public List<GithubJson> GhAuthJson = [];
|
||||
|
||||
public string latestVersion = "";
|
||||
|
||||
#region Singelton
|
||||
|
||||
private static Updater? _instance;
|
||||
private static readonly object Padlock = new();
|
||||
|
||||
public static Updater Instance{
|
||||
get{
|
||||
if (_instance == null){
|
||||
lock (Padlock){
|
||||
if (_instance == null){
|
||||
_instance = new Updater();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string propertyName = null){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
public static Updater Instance{ get; } = new();
|
||||
|
||||
private string downloadUrl = "";
|
||||
private readonly string tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update.zip");
|
||||
private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "ExtractedUpdate");
|
||||
private readonly string tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update", "Update.zip");
|
||||
private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update", "ExtractedUpdate");
|
||||
private readonly string changelogFilePath = Path.Combine(AppContext.BaseDirectory, "CHANGELOG.md");
|
||||
|
||||
private static readonly string apiEndpoint = "https://api.github.com/repos/Crunchy-DL/Crunchy-Downloader/releases";
|
||||
private static readonly string apiEndpointLatest = apiEndpoint + "/latest";
|
||||
private static readonly string ApiEndpoint = "https://api.github.com/repos/Crunchy-DL/Crunchy-Downloader/releases";
|
||||
private static readonly string ApiEndpointLatest = ApiEndpoint + "/latest";
|
||||
|
||||
public async Task<bool> CheckForUpdatesAsync(){
|
||||
if (File.Exists(tempPath)){
|
||||
|
|
@ -88,39 +68,49 @@ public class Updater : INotifyPropertyChanged{
|
|||
|
||||
Console.WriteLine($"Running on {platformName}");
|
||||
|
||||
var infoVersion = Assembly
|
||||
.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion
|
||||
.Split('+')[0];
|
||||
|
||||
var currentVersion = NuGetVersion.Parse(infoVersion ?? "0.0.0");
|
||||
|
||||
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(apiEndpointLatest);
|
||||
var releaseInfo = Helpers.Deserialize<GithubRelease>(response, null);
|
||||
var response = await client.GetStringAsync(ApiEndpoint);
|
||||
var releases = Helpers.Deserialize<List<GithubRelease>>(response, null) ?? [];
|
||||
|
||||
if (releaseInfo == null){
|
||||
Console.WriteLine($"Failed to get Update info");
|
||||
bool allowPrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
|
||||
|
||||
var selectedRelease = releases
|
||||
.FirstOrDefault(r => allowPrereleases || !r.Prerelease);
|
||||
|
||||
|
||||
if (selectedRelease == null){
|
||||
Console.WriteLine("No valid releases found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
latestVersion = releaseInfo.TagName;
|
||||
LatestVersion = selectedRelease.TagName;
|
||||
|
||||
if (releaseInfo.Assets != null)
|
||||
foreach (var asset in releaseInfo.Assets){
|
||||
string assetName = (string)asset.name;
|
||||
if (assetName.Contains(platformName)){
|
||||
downloadUrl = asset.browser_download_url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var latestVersion = NuGetVersion.Parse(selectedRelease.TagName.TrimStart('v'));
|
||||
|
||||
if (string.IsNullOrEmpty(downloadUrl)){
|
||||
if (latestVersion > currentVersion){
|
||||
Console.WriteLine($"Update available: {LatestVersion} - Current Version: {currentVersion}");
|
||||
|
||||
var asset = selectedRelease.Assets?
|
||||
.FirstOrDefault(a => a.IsForPlatform(platformName));
|
||||
|
||||
if (asset == null){
|
||||
Console.WriteLine($"Failed to get Update url for {platformName}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
var currentVersion = $"v{version?.Major}.{version?.Minor}.{version?.Build}";
|
||||
downloadUrl = asset.BrowserDownloadUrl;
|
||||
|
||||
if (latestVersion != currentVersion){
|
||||
Console.WriteLine("Update available: " + latestVersion + " - Current Version: " + currentVersion);
|
||||
_ = UpdateChangelogAsync();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -135,6 +125,25 @@ public class Updater : INotifyPropertyChanged{
|
|||
}
|
||||
}
|
||||
|
||||
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();
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
|
||||
|
|
@ -145,17 +154,25 @@ public class Updater : INotifyPropertyChanged{
|
|||
existingVersion = "v1.0.0";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(latestVersion)){
|
||||
latestVersion = "v1.0.0";
|
||||
if (string.IsNullOrEmpty(LatestVersion)){
|
||||
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;
|
||||
}
|
||||
|
||||
try{
|
||||
string jsonResponse = await client.GetStringAsync(apiEndpoint); // + "?per_page=100&page=1"
|
||||
string jsonResponse = await client.GetStringAsync(ApiEndpoint); // + "?per_page=100&page=1"
|
||||
|
||||
var releases = Helpers.Deserialize<List<GithubRelease>>(jsonResponse, null);
|
||||
|
||||
|
|
@ -191,10 +208,16 @@ public class Updater : INotifyPropertyChanged{
|
|||
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;
|
||||
|
|
@ -231,7 +254,7 @@ public class Updater : INotifyPropertyChanged{
|
|||
|
||||
public async Task DownloadAndUpdateAsync(){
|
||||
try{
|
||||
failed = false;
|
||||
Failed = false;
|
||||
Helpers.EnsureDirectoriesExist(tempPath);
|
||||
|
||||
// Download the zip file
|
||||
|
|
@ -249,8 +272,8 @@ public class Updater : INotifyPropertyChanged{
|
|||
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
|
||||
if (bytesRead == 0){
|
||||
isMoreToRead = false;
|
||||
progress = 100;
|
||||
OnPropertyChanged(nameof(progress));
|
||||
Progress = 100;
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -258,8 +281,8 @@ public class Updater : INotifyPropertyChanged{
|
|||
|
||||
totalBytesRead += bytesRead;
|
||||
if (totalBytes != -1){
|
||||
progress = (double)totalBytesRead / totalBytes * 100;
|
||||
OnPropertyChanged(nameof(progress));
|
||||
Progress = (double)totalBytesRead / totalBytes * 100;
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
}
|
||||
} while (isMoreToRead);
|
||||
}
|
||||
|
|
@ -273,13 +296,13 @@ public class Updater : INotifyPropertyChanged{
|
|||
ApplyUpdate(extractPath);
|
||||
} else{
|
||||
Console.Error.WriteLine("Failed to get Update");
|
||||
failed = true;
|
||||
OnPropertyChanged(nameof(failed));
|
||||
Failed = true;
|
||||
OnPropertyChanged(nameof(Failed));
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.Error.WriteLine($"Failed to get Update: {e.Message}");
|
||||
failed = true;
|
||||
OnPropertyChanged(nameof(failed));
|
||||
Failed = true;
|
||||
OnPropertyChanged(nameof(Failed));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +324,8 @@ public class Updater : INotifyPropertyChanged{
|
|||
System.Diagnostics.Process.Start(chmodProcess)?.WaitForExit();
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error setting execute permissions: {ex.Message}");
|
||||
failed = true;
|
||||
OnPropertyChanged(nameof(failed));
|
||||
Failed = true;
|
||||
OnPropertyChanged(nameof(Failed));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -319,16 +342,28 @@ public class Updater : INotifyPropertyChanged{
|
|||
Environment.Exit(0);
|
||||
} catch (Exception ex){
|
||||
Console.Error.WriteLine($"Error launching updater: {ex.Message}");
|
||||
failed = true;
|
||||
OnPropertyChanged(nameof(failed));
|
||||
Failed = true;
|
||||
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")]
|
||||
public string TagName{ get; set; } = string.Empty;
|
||||
|
||||
public dynamic? Assets{ get; set; }
|
||||
public List<GithubAsset>? Assets{ get; set; } = [];
|
||||
public string Body{ get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("published_at")]
|
||||
|
|
@ -336,4 +371,50 @@ public class Updater : INotifyPropertyChanged{
|
|||
|
||||
public bool Prerelease{ get; set; }
|
||||
}
|
||||
|
||||
public class GithubAsset{
|
||||
[JsonProperty("url")]
|
||||
public string Url{ get; set; } = "";
|
||||
|
||||
[JsonProperty("id")]
|
||||
public long Id{ get; set; }
|
||||
|
||||
[JsonProperty("node_id")]
|
||||
public string NodeId{ get; set; } = "";
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name{ get; set; } = "";
|
||||
|
||||
[JsonProperty("label")]
|
||||
public string? Label{ get; set; }
|
||||
|
||||
[JsonProperty("content_type")]
|
||||
public string ContentType{ get; set; } = "";
|
||||
|
||||
[JsonProperty("state")]
|
||||
public string State{ get; set; } = "";
|
||||
|
||||
[JsonProperty("size")]
|
||||
public long Size{ get; set; }
|
||||
|
||||
[JsonProperty("digest")]
|
||||
public string? Digest{ get; set; }
|
||||
|
||||
[JsonProperty("download_count")]
|
||||
public int DownloadCount{ get; set; }
|
||||
|
||||
[JsonProperty("created_at")]
|
||||
public DateTime CreatedAt{ get; set; }
|
||||
|
||||
[JsonProperty("updated_at")]
|
||||
public DateTime UpdatedAt{ get; set; }
|
||||
|
||||
[JsonProperty("browser_download_url")]
|
||||
public string BrowserDownloadUrl{ get; set; } = "";
|
||||
|
||||
|
||||
public bool IsForPlatform(string platform){
|
||||
return Name.Contains(platform, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.UI;
|
||||
using CRD.ViewModels.Utils;
|
||||
using CRD.Views.Utils;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Newtonsoft.Json;
|
||||
|
|
@ -23,6 +26,9 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private string _loginLogoutText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasMultiProfile;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _remainingTime = "";
|
||||
|
||||
|
|
@ -50,8 +56,8 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
RemainingTime = "Subscription maybe ended";
|
||||
}
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
|
||||
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}";
|
||||
|
|
@ -59,13 +65,18 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
public void UpdatetProfile(){
|
||||
ProfileName = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username ?? CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.ProfileName ?? "???"; // Default or fetched user name
|
||||
LoginLogoutText = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???" ? "Login" : "Logout"; // Default state
|
||||
|
||||
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
|
||||
var firstEndpointProfile = firstEndpoint.Profile;
|
||||
|
||||
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
|
||||
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
|
||||
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
|
||||
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
|
||||
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar));
|
||||
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
|
||||
|
||||
|
||||
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription;
|
||||
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
|
||||
|
||||
if (subscriptions != null){
|
||||
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
|
||||
|
|
@ -84,8 +95,8 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
UnknownEndDate = true;
|
||||
}
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){
|
||||
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate;
|
||||
if (!UnknownEndDate){
|
||||
_targetTime = subscriptions.NextRenewalDate;
|
||||
_timer = new DispatcherTimer{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
|
@ -101,8 +112,8 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
|
||||
RaisePropertyChanged(nameof(RemainingTime));
|
||||
|
||||
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
|
||||
if (subscriptions != null){
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +148,41 @@ public partial class AccountPageViewModel : ViewModelBase{
|
|||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task OpenMultiProfileDialog(){
|
||||
var multiProfile = CrunchyrollManager.Instance.CrAuthEndpoint1.MultiProfile;
|
||||
|
||||
var profiels = multiProfile.Profiles.Select(multiProfileProfile => new AccountProfile{
|
||||
AvatarUrl = string.IsNullOrEmpty(multiProfileProfile.Avatar) ? "" : ("https://static.crunchyroll.com/assets/avatar/170x170/" + multiProfileProfile.Avatar),
|
||||
ProfileName = multiProfileProfile.Username ?? multiProfileProfile.ProfileName ?? "???", CanBeSelected = multiProfileProfile is{ IsSelected: false, CanSwitch: true, IsPinProtected: false },
|
||||
ProfileId = multiProfileProfile.ProfileId,
|
||||
}).ToList();
|
||||
|
||||
var dialog = new CustomContentDialog(){
|
||||
Name = "CRD Select Profile",
|
||||
Title = "Select Profile",
|
||||
IsPrimaryButtonEnabled = false,
|
||||
CloseButtonText = "Close",
|
||||
FullSizeDesired = true,
|
||||
};
|
||||
|
||||
var viewModel = new ContentDialogMultiProfileSelectViewModel(dialog, profiels);
|
||||
dialog.Content = new ContentDialogMultiProfileSelectView(){
|
||||
DataContext = viewModel
|
||||
};
|
||||
|
||||
var dialogResult = await dialog.ShowAsync();
|
||||
|
||||
if (dialogResult == ContentDialogResult.Primary){
|
||||
var selectedProfile = viewModel.SelectedItem;
|
||||
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint1.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
|
||||
await CrunchyrollManager.Instance.CrAuthEndpoint2.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
|
||||
|
||||
UpdatetProfile();
|
||||
}
|
||||
}
|
||||
|
||||
public async void LoadProfileImage(string imageUrl){
|
||||
try{
|
||||
ProfileImage = await Helpers.LoadImage(imageUrl);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ public partial class CalendarPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _showUpcomingEpisodes;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _updateHistoryFromCalendar;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hideDubs;
|
||||
|
||||
|
|
@ -74,6 +77,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
|
|||
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
|
||||
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
|
||||
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes;
|
||||
UpdateHistoryFromCalendar = CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar;
|
||||
|
||||
ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
|
||||
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
|
||||
|
|
@ -289,4 +293,14 @@ public partial class CalendarPageViewModel : ViewModelBase{
|
|||
CfgManager.WriteCrSettings();
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnUpdateHistoryFromCalendarChanged(bool value){
|
||||
if (loading){
|
||||
return;
|
||||
}
|
||||
|
||||
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
|
||||
CfgManager.WriteCrSettings();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -20,16 +20,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 +63,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,8 +85,20 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,6 +126,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
|
||||
|
||||
public bool Error{ get; set; }
|
||||
public bool ShowPauseIcon{ get; set; }
|
||||
|
||||
public DownloadItemModel(CrunchyEpMeta epMetaF){
|
||||
epMeta = epMetaF;
|
||||
|
|
@ -122,9 +135,9 @@ 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
|
||||
|
|
@ -132,8 +145,8 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
|
||||
|
||||
;
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" :
|
||||
Paused = epMeta.DownloadProgress.IsPaused;
|
||||
DoingWhat = Paused ? "Paused" :
|
||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
|
||||
|
|
@ -144,7 +157,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
);
|
||||
InfoTextHover = epMeta.AvailableQualities;
|
||||
|
||||
Error = epMeta.DownloadProgress.Error;
|
||||
Error = epMeta.DownloadProgress.IsError;
|
||||
}
|
||||
|
||||
string JoinWithSeparator(params string[] parts){
|
||||
|
|
@ -192,16 +205,17 @@ 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";
|
||||
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" :
|
||||
Paused = epMeta.DownloadProgress.IsPaused;
|
||||
DoingWhat = Paused ? "Paused" :
|
||||
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
|
||||
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
|
||||
|
|
@ -211,11 +225,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)));
|
||||
|
|
@ -231,52 +246,97 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
|
|||
|
||||
[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(ShowPauseIcon)));
|
||||
|
||||
QueueManager.Instance.ReleaseDownloadSlot(epMeta);
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (epMeta.DownloadProgress.IsPaused){
|
||||
if (!QueueManager.Instance.TryResumeDownload(epMeta))
|
||||
return;
|
||||
|
||||
epMeta.DownloadProgress.State = epMeta.DownloadProgress.ResumeState;
|
||||
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
|
||||
Paused = false;
|
||||
ShowPauseIcon = isDownloading;
|
||||
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
} else{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
|
||||
return;
|
||||
}
|
||||
|
||||
StartDownload();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RetryDownload(){
|
||||
epMeta.DownloadProgress.ResetForRetry();
|
||||
isDownloading = false;
|
||||
Paused = false;
|
||||
ShowPauseIcon = false;
|
||||
|
||||
if (PropertyChanged != null){
|
||||
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading"));
|
||||
}
|
||||
}
|
||||
|
||||
public async void StartDownload(){
|
||||
if (!isDownloading){
|
||||
isDownloading = true;
|
||||
epMeta.DownloadProgress.IsDownloading = true;
|
||||
Paused = !epMeta.Paused && !isDownloading || epMeta.Paused;
|
||||
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)));
|
||||
|
||||
CrDownloadOptions newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
|
||||
QueueManager.Instance.RefreshQueue();
|
||||
StartDownload();
|
||||
}
|
||||
|
||||
public Task StartDownload(){
|
||||
QueueManager.Instance.TryStartDownload(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal async Task StartDownloadCore(){
|
||||
if (isDownloading)
|
||||
return;
|
||||
|
||||
epMeta.RenewCancellationToken();
|
||||
isDownloading = true;
|
||||
epMeta.DownloadProgress.State = DownloadState.Downloading;
|
||||
Paused = false;
|
||||
ShowPauseIcon = true;
|
||||
|
||||
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{
|
||||
|
|
|
|||
|
|
@ -44,17 +44,17 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private ComboBoxItem? _selectedView;
|
||||
|
||||
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[];
|
||||
public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private SortingListElement? _selectedSorting;
|
||||
|
||||
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
|
||||
public ObservableCollection<SortingListElement> SortingList{ get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private FilterListElement? _selectedFilter;
|
||||
|
||||
public ObservableCollection<FilterListElement> FilterList{ get; } =[];
|
||||
public ObservableCollection<FilterListElement> FilterList{ get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private double _posterWidth;
|
||||
|
|
@ -87,6 +87,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private bool _showArtists;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _showMovies = true;
|
||||
|
||||
[ObservableProperty]
|
||||
private static bool _viewSelectionOpen;
|
||||
|
||||
|
|
@ -116,6 +119,15 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private static string _progressText;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _searchInput;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSearchOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
public bool _isSearchActiveClosed;
|
||||
|
||||
#region Table Mode
|
||||
|
||||
[ObservableProperty]
|
||||
|
|
@ -126,7 +138,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
|
||||
#endregion
|
||||
|
||||
public Vector LastScrollOffset { get; set; } = Vector.Zero;
|
||||
public Vector LastScrollOffset{ get; set; } = Vector.Zero;
|
||||
|
||||
public HistoryPageViewModel(){
|
||||
ProgramManager = ProgramManager.Instance;
|
||||
|
|
@ -151,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 };
|
||||
|
|
@ -265,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){
|
||||
|
|
@ -324,11 +345,36 @@ 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);
|
||||
|
||||
filteredItems.RemoveAll(item => {
|
||||
var title = item.SeriesTitle ?? string.Empty;
|
||||
|
||||
return tokens.Any(t => title.IndexOf(t, StringComparison.OrdinalIgnoreCase) < 0);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
FilteredItems.Clear();
|
||||
FilteredItems.AddRange(filteredItems);
|
||||
}
|
||||
|
||||
|
||||
partial void OnSearchInputChanged(string value){
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
partial void OnIsSearchOpenChanged(bool value){
|
||||
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
|
||||
}
|
||||
|
||||
|
||||
partial void OnScaleValueChanged(double value){
|
||||
double t = (ScaleValue - 0.5) / (1 - 0.5);
|
||||
|
||||
|
|
@ -374,6 +420,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void ClearSearchCommand(){
|
||||
SearchInput = "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void NavToSeries(){
|
||||
|
|
@ -419,7 +469,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
.SelectMany(item => item.Seasons)
|
||||
.SelectMany(season => season.EpisodesList)
|
||||
.Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile)
|
||||
.Select(historyEpisode => historyEpisode.DownloadEpisode())
|
||||
.Select(historyEpisode => historyEpisode.DownloadEpisodeDefault())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +565,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public async Task DownloadSeasonAll(HistorySeason season){
|
||||
foreach (var episode in season.EpisodesList){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,7 +578,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
|
||||
} else{
|
||||
foreach (var episode in missingEpisodes){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -536,7 +586,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
|
||||
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,7 +595,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
var downloadMode = SelectedDownloadMode;
|
||||
|
||||
if (downloadMode != EpisodeDownloadMode.Default){
|
||||
await episode.DownloadEpisode(downloadMode);
|
||||
await episode.DownloadEpisode(downloadMode,"",false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -555,7 +605,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
|
||||
if (downloadMode != EpisodeDownloadMode.Default){
|
||||
foreach (var episode in season.EpisodesList){
|
||||
await episode.DownloadEpisode(downloadMode);
|
||||
await episode.DownloadEpisode(downloadMode,"",false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -567,9 +617,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
|
|||
|
||||
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
|
||||
if (historyEpisode.WasDownloaded == allDownloaded){
|
||||
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId);
|
||||
historyEpisode.ToggleWasDownloaded();
|
||||
}
|
||||
}
|
||||
seriesArgs.Season.UpdateDownloaded();
|
||||
}
|
||||
|
||||
seriesArgs.Series?.UpdateNewEpisodes();
|
||||
|
|
@ -627,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{
|
||||
|
|
|
|||
|
|
@ -109,6 +109,20 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
SelectedSeries.UpdateSeriesFolderPath();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void ClearFolderPathCommand(HistorySeason? season){
|
||||
|
||||
if (season != null){
|
||||
season.SeasonDownloadPath = string.Empty;
|
||||
} else{
|
||||
SelectedSeries.SeriesDownloadPath = string.Empty;
|
||||
}
|
||||
|
||||
CfgManager.UpdateHistoryFile();
|
||||
SelectedSeries.UpdateSeriesFolderPath();
|
||||
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task OpenFeaturedMusicDialog(){
|
||||
if (SelectedSeries.SeriesStreamingService != StreamingService.Crunchyroll || SelectedSeries.SeriesType == SeriesType.Artist){
|
||||
|
|
@ -213,7 +227,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public async Task DownloadSeasonAll(HistorySeason season){
|
||||
foreach (var episode in season.EpisodesList){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +240,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
|
||||
} else{
|
||||
foreach (var episode in missingEpisodes){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +250,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
var downloadMode = SelectedDownloadMode;
|
||||
|
||||
if (downloadMode != EpisodeDownloadMode.Default){
|
||||
await episode.DownloadEpisode(downloadMode);
|
||||
await episode.DownloadEpisode(downloadMode,"",false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +260,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
|
||||
if (downloadMode != EpisodeDownloadMode.Default){
|
||||
foreach (var episode in season.EpisodesList){
|
||||
await episode.DownloadEpisode(downloadMode);
|
||||
await episode.DownloadEpisode(downloadMode,"",false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +268,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
[RelayCommand]
|
||||
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
|
||||
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
|
||||
await episode.DownloadEpisode();
|
||||
await episode.DownloadEpisodeDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,11 +276,11 @@ public partial class SeriesPageViewModel : ViewModelBase{
|
|||
public void ToggleDownloadedMark(HistorySeason season){
|
||||
bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded);
|
||||
|
||||
foreach (var historyEpisode in season.EpisodesList){
|
||||
if (historyEpisode.WasDownloaded == allDownloaded){
|
||||
season.UpdateDownloaded(historyEpisode.EpisodeId);
|
||||
}
|
||||
foreach (var historyEpisode in season.EpisodesList.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){
|
||||
historyEpisode.ToggleWasDownloaded();
|
||||
}
|
||||
|
||||
season.UpdateDownloaded();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using CRD.Downloader;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.History;
|
||||
using CRD.Views;
|
||||
|
|
@ -168,11 +169,11 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
[ObservableProperty]
|
||||
private static bool _sortDir;
|
||||
|
||||
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
|
||||
public ObservableCollection<SortingListElement> SortingList{ get; } = [];
|
||||
|
||||
public ObservableCollection<SeasonViewModel> Seasons{ get; set; } =[];
|
||||
public ObservableCollection<SeasonViewModel> Seasons{ get; set; } = [];
|
||||
|
||||
public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } =[];
|
||||
public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } = [];
|
||||
|
||||
private SeasonViewModel currentSelection;
|
||||
|
||||
|
|
@ -213,8 +214,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
|
||||
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
|
||||
if (crunchySeries != null){
|
||||
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
|
||||
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
|
||||
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ?? []));
|
||||
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -240,8 +241,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
|
||||
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
|
||||
if (crunchySeries != null){
|
||||
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
|
||||
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
|
||||
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ?? []));
|
||||
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,7 +253,6 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
public void OpenTrailer(AnilistSeries series){
|
||||
if (series.Trailer != null){
|
||||
|
|
@ -333,7 +333,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
|
||||
hasNext = pageNode?.PageInfo?.HasNextPage ?? false;
|
||||
page++;
|
||||
} while (hasNext || page <= maxPage);
|
||||
} while (hasNext && page <= maxPage);
|
||||
|
||||
var list = allMedia.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external =>
|
||||
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
|
|
@ -462,45 +462,27 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private ObservableCollection<SeasonViewModel> GetTargetSeasonsAndYears(){
|
||||
DateTime now = DateTime.Now;
|
||||
int currentMonth = now.Month;
|
||||
var seasons = new[]{ "WINTER", "SPRING", "SUMMER", "FALL" };
|
||||
|
||||
var now = DateTime.Now;
|
||||
int currentYear = now.Year;
|
||||
int currentSeasonIndex = (now.Month - 1) / 3;
|
||||
|
||||
string currentSeason;
|
||||
if (currentMonth >= 1 && currentMonth <= 3)
|
||||
currentSeason = "WINTER";
|
||||
else if (currentMonth >= 4 && currentMonth <= 6)
|
||||
currentSeason = "SPRING";
|
||||
else if (currentMonth >= 7 && currentMonth <= 9)
|
||||
currentSeason = "SUMMER";
|
||||
else
|
||||
currentSeason = "FALL";
|
||||
var result = new ObservableCollection<SeasonViewModel>();
|
||||
|
||||
|
||||
var seasons = new List<string>{ "WINTER", "SPRING", "SUMMER", "FALL" };
|
||||
|
||||
int currentSeasonIndex = seasons.IndexOf(currentSeason);
|
||||
|
||||
var targetSeasons = new ObservableCollection<SeasonViewModel>();
|
||||
|
||||
// Includes: -2 (two seasons ago), -1 (previous), 0 (current), 1 (next)
|
||||
for (int i = -2; i <= 1; i++){
|
||||
int targetIndex = (currentSeasonIndex + i + 4) % 4;
|
||||
string targetSeason = seasons[targetIndex];
|
||||
int targetYear = currentYear;
|
||||
int rawIndex = currentSeasonIndex + i;
|
||||
|
||||
int yearOffset = (int)Math.Floor(rawIndex / 4.0);
|
||||
int seasonIndex = (rawIndex % 4 + 4) % 4;
|
||||
|
||||
if (i < 0 && targetIndex == 3){
|
||||
targetYear--;
|
||||
} else if (i > 0 && targetIndex == 0){
|
||||
targetYear++;
|
||||
result.Add(new SeasonViewModel{
|
||||
Season = seasons[seasonIndex],
|
||||
Year = currentYear + yearOffset
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
targetSeasons.Add(new SeasonViewModel(){ Season = targetSeason, Year = targetYear });
|
||||
}
|
||||
|
||||
return targetSeasons;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void SelectionChangedOfSeries(AnilistSeries? value){
|
||||
|
|
@ -584,7 +566,6 @@ public partial class UpcomingPageViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private void FilterItems(){
|
||||
|
||||
List<AnilistSeries> filteredList;
|
||||
|
||||
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Linq;
|
|||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
|
@ -16,6 +17,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Updater;
|
||||
using Markdig;
|
||||
using Markdig.Syntax;
|
||||
|
|
@ -26,35 +28,50 @@ namespace CRD.ViewModels;
|
|||
|
||||
public partial class UpdateViewModel : ViewModelBase{
|
||||
[ObservableProperty]
|
||||
private bool _updateAvailable;
|
||||
private bool updating;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _updating;
|
||||
private double progress;
|
||||
|
||||
[ObservableProperty]
|
||||
private double _progress;
|
||||
private bool failed;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _failed;
|
||||
|
||||
private AccountPageViewModel accountPageViewModel;
|
||||
private string currentVersion;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _currentVersion;
|
||||
private bool ghUpdatePrereleases;
|
||||
|
||||
public ObservableCollection<Control> ChangelogBlocks{ get; } = new();
|
||||
|
||||
public ProgramManager ProgramManager { get; }
|
||||
|
||||
public UpdateViewModel(){
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
|
||||
var version = Assembly
|
||||
.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion.Split('+')[0];
|
||||
CurrentVersion = $"v{version}";
|
||||
|
||||
ProgramManager = ProgramManager.Instance;
|
||||
|
||||
LoadChangelog();
|
||||
|
||||
UpdateAvailable = ProgramManager.Instance.UpdateAvailable;
|
||||
GhUpdatePrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
|
||||
|
||||
Updater.Instance.PropertyChanged += Progress_PropertyChanged;
|
||||
}
|
||||
|
||||
partial void OnGhUpdatePrereleasesChanged(bool value){
|
||||
CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases = value;
|
||||
CfgManager.WriteCrSettings();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task CheckForUpdate(){
|
||||
ProgramManager.UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void StartUpdate(){
|
||||
Updating = true;
|
||||
|
|
@ -63,10 +80,10 @@ public partial class UpdateViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private void Progress_PropertyChanged(object? sender, PropertyChangedEventArgs e){
|
||||
if (e.PropertyName == nameof(Updater.Instance.progress)){
|
||||
Progress = Updater.Instance.progress;
|
||||
} else if (e.PropertyName == nameof(Updater.Instance.failed)){
|
||||
Failed = Updater.Instance.failed;
|
||||
if (e.PropertyName == nameof(Updater.Instance.Progress)){
|
||||
Progress = Updater.Instance.Progress;
|
||||
} else if (e.PropertyName == nameof(Updater.Instance.Failed)){
|
||||
Failed = Updater.Instance.Failed;
|
||||
ProgramManager.Instance.NavigationLock = !Failed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,34 +19,37 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
|
|||
private readonly ContentDialog dialog;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _editMode;
|
||||
private bool editMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _presetName;
|
||||
private string presetName;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _codec;
|
||||
[NotifyPropertyChangedFor(nameof(HasCodec))]
|
||||
private string codec;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem _selectedResolution = new();
|
||||
private ComboBoxItem selectedResolution = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _crf = 23;
|
||||
private double? crf = 23;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _frameRate = "";
|
||||
private string frameRate = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _additionalParametersString = "";
|
||||
private string additionalParametersString = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<StringItem> _additionalParameters = new();
|
||||
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 ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
|
||||
|
||||
|
|
@ -108,7 +111,7 @@ 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();
|
||||
AdditionalParameters.Clear();
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
|
|||
|
||||
[RelayCommand]
|
||||
public void DownloadEpisode(HistoryEpisode episode){
|
||||
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath);
|
||||
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath,false);
|
||||
}
|
||||
|
||||
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.UI;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.ViewModels.Utils;
|
||||
|
||||
public partial class ContentDialogMultiProfileSelectViewModel: ViewModelBase{
|
||||
|
||||
private readonly CustomContentDialog dialog;
|
||||
|
||||
[ObservableProperty]
|
||||
private AccountProfile _selectedItem;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<AccountProfile> _profileList = [];
|
||||
|
||||
public ContentDialogMultiProfileSelectViewModel(CustomContentDialog contentDialog, List<AccountProfile> profiles){
|
||||
ArgumentNullException.ThrowIfNull(contentDialog);
|
||||
|
||||
dialog = contentDialog;
|
||||
dialog.Closed += DialogOnClosed;
|
||||
dialog.PrimaryButtonClick += SaveButton;
|
||||
|
||||
try{
|
||||
_ = LoadProfiles(profiles);
|
||||
} catch (Exception e){
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadProfiles(List<AccountProfile> profiles){
|
||||
foreach (var accountProfile in profiles){
|
||||
accountProfile.ProfileImage = await Helpers.LoadImage(accountProfile.AvatarUrl);
|
||||
ProfileList.Add(accountProfile);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedItemChanged(AccountProfile value){
|
||||
dialog.Hide(ContentDialogResult.Primary);
|
||||
}
|
||||
|
||||
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
||||
dialog.PrimaryButtonClick -= SaveButton;
|
||||
}
|
||||
|
||||
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
|
||||
dialog.Closed -= DialogOnClosed;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
|
@ -18,104 +19,144 @@ using CRD.Downloader;
|
|||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Files;
|
||||
using CRD.Utils.Http;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Utils.Structs.Crunchyroll;
|
||||
using CRD.Utils.Structs.History;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace CRD.ViewModels.Utils;
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
public partial class GeneralSettingsViewModel : ViewModelBase{
|
||||
[ObservableProperty]
|
||||
private string _currentVersion;
|
||||
private string currentVersion;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _downloadToTempFolder;
|
||||
private bool downloadToTempFolder;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _history;
|
||||
private bool history;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _historyCountMissing;
|
||||
private bool historyCountMissing;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _historyIncludeCrArtists;
|
||||
private bool historyIncludeCrArtists;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _historyAddSpecials;
|
||||
private bool historyRemoveMissingEpisodes;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _historySkipUnmonitored;
|
||||
private bool historyAddSpecials;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _historyCountSonarr;
|
||||
private bool historySkipUnmonitored;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _simultaneousDownloads;
|
||||
private bool historyCountSonarr;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _simultaneousProcessingJobs;
|
||||
private double? historyAutoRefreshIntervalMinutes;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _downloadMethodeNew;
|
||||
private HistoryRefreshMode historyAutoRefreshMode;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _downloadAllowEarlyStart;
|
||||
private string historyAutoRefreshModeHint;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _downloadSpeed;
|
||||
private string historyAutoRefreshLastRunTime;
|
||||
|
||||
public ObservableCollection<RefreshModeOption> HistoryAutoRefreshModes{ get; } = new(){
|
||||
new RefreshModeOption{ DisplayName = "Default All", value = HistoryRefreshMode.DefaultAll },
|
||||
new RefreshModeOption{ DisplayName = "Default Active", value = HistoryRefreshMode.DefaultActive },
|
||||
new RefreshModeOption{ DisplayName = "Fast New Releases", value = HistoryRefreshMode.FastNewReleases },
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _downloadSpeedInBits;
|
||||
private double? simultaneousDownloads;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _retryAttempts;
|
||||
private double? simultaneousProcessingJobs;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _retryDelay;
|
||||
private bool downloadMethodeNew;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem _selectedHistoryLang;
|
||||
private bool downloadOnlyWithAllSelectedDubSub;
|
||||
|
||||
[ObservableProperty]
|
||||
private ComboBoxItem? _currentAppTheme;
|
||||
private bool downloadAllowEarlyStart;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _useCustomAccent;
|
||||
private bool persistQueue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _backgroundImagePath;
|
||||
private double? downloadSpeed;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _backgroundImageOpacity;
|
||||
private bool downloadSpeedInBits;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _backgroundImageBlurRadius;
|
||||
private double? retryAttempts;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _listBoxColor;
|
||||
private double? retryDelay;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color _customAccentColor = Colors.SlateBlue;
|
||||
private bool trayIconEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sonarrHost = "localhost";
|
||||
private bool startMinimizedToTray;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sonarrPort = "8989";
|
||||
private bool minimizeToTray;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sonarrApiKey = "";
|
||||
private bool minimizeToTrayOnClose;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _sonarrUseSsl = false;
|
||||
private ComboBoxItem selectedHistoryLang;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _sonarrUseSonarrNumbering = false;
|
||||
private ComboBoxItem? currentAppTheme;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _logMode = false;
|
||||
private bool useCustomAccent;
|
||||
|
||||
[ObservableProperty]
|
||||
private string backgroundImagePath;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? backgroundImageOpacity;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? backgroundImageBlurRadius;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color listBoxColor;
|
||||
|
||||
[ObservableProperty]
|
||||
private Color customAccentColor = Colors.SlateBlue;
|
||||
|
||||
[ObservableProperty]
|
||||
private string sonarrHost = "localhost";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sonarrPort = "8989";
|
||||
|
||||
[ObservableProperty]
|
||||
private string sonarrApiKey = "";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sonarrUseSsl;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool sonarrUseSonarrNumbering;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool logMode;
|
||||
|
||||
public ObservableCollection<Color> PredefinedColors{ get; } = new(){
|
||||
Color.FromRgb(255, 185, 0),
|
||||
|
|
@ -170,84 +211,105 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> AppThemes{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "System" },
|
||||
new ComboBoxItem(){ Content = "Light" },
|
||||
new ComboBoxItem(){ Content = "Dark" },
|
||||
new ComboBoxItem{ Content = "System" },
|
||||
new ComboBoxItem{ Content = "Light" },
|
||||
new ComboBoxItem{ Content = "Dark" },
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> HistoryLangList{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "default" },
|
||||
new ComboBoxItem(){ Content = "de-DE" },
|
||||
new ComboBoxItem(){ Content = "en-US" },
|
||||
new ComboBoxItem(){ Content = "es-419" },
|
||||
new ComboBoxItem(){ Content = "es-ES" },
|
||||
new ComboBoxItem(){ Content = "fr-FR" },
|
||||
new ComboBoxItem(){ Content = "it-IT" },
|
||||
new ComboBoxItem(){ Content = "pt-BR" },
|
||||
new ComboBoxItem(){ Content = "pt-PT" },
|
||||
new ComboBoxItem(){ Content = "ru-RU" },
|
||||
new ComboBoxItem(){ Content = "hi-IN" },
|
||||
new ComboBoxItem(){ Content = "ar-SA" },
|
||||
new ComboBoxItem{ Content = "default" },
|
||||
new ComboBoxItem{ Content = "de-DE" },
|
||||
new ComboBoxItem{ Content = "en-US" },
|
||||
new ComboBoxItem{ Content = "es-419" },
|
||||
new ComboBoxItem{ Content = "es-ES" },
|
||||
new ComboBoxItem{ Content = "fr-FR" },
|
||||
new ComboBoxItem{ Content = "it-IT" },
|
||||
new ComboBoxItem{ Content = "pt-BR" },
|
||||
new ComboBoxItem{ Content = "pt-PT" },
|
||||
new ComboBoxItem{ Content = "ru-RU" },
|
||||
new ComboBoxItem{ Content = "hi-IN" },
|
||||
new ComboBoxItem{ Content = "ar-SA" },
|
||||
};
|
||||
|
||||
[ObservableProperty]
|
||||
private string _downloadDirPath;
|
||||
private string downloadDirPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _proxyEnabled;
|
||||
private bool proxyEnabled;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _proxySocks;
|
||||
private bool proxySocks;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _proxyHost;
|
||||
private string proxyHost;
|
||||
|
||||
[ObservableProperty]
|
||||
private double? _proxyPort;
|
||||
private double? proxyPort;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _proxyUsername;
|
||||
private string proxyUsername;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _proxyPassword;
|
||||
private string proxyPassword;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _flareSolverrHost = "localhost";
|
||||
private string flareSolverrHost = "localhost";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _flareSolverrPort = "8191";
|
||||
private string flareSolverrPort = "8191";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _flareSolverrUseSsl = false;
|
||||
private bool flareSolverrUseSsl;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _useFlareSolverr = false;
|
||||
private bool useFlareSolverr;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _tempDownloadDirPath;
|
||||
private string mitmFlareSolverrHost = "localhost";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _downloadFinishedPlaySound;
|
||||
private string mitmFlareSolverrPort = "8080";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _downloadFinishedSoundPath;
|
||||
private bool mitmFlareSolverrUseSsl;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _currentIp = "";
|
||||
private bool useMitmFlareSolverr;
|
||||
|
||||
private readonly FluentAvaloniaTheme _faTheme;
|
||||
[ObservableProperty]
|
||||
private string tempDownloadDirPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool downloadFinishedPlaySound;
|
||||
|
||||
[ObservableProperty]
|
||||
private string downloadFinishedSoundPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool downloadFinishedExecute;
|
||||
|
||||
[ObservableProperty]
|
||||
private string downloadFinishedExecutePath;
|
||||
|
||||
[ObservableProperty]
|
||||
private string currentIp = "";
|
||||
|
||||
private readonly FluentAvaloniaTheme faTheme;
|
||||
|
||||
private bool settingsLoaded;
|
||||
|
||||
private IStorageProvider _storageProvider;
|
||||
private IStorageProvider? storageProvider;
|
||||
|
||||
public GeneralSettingsViewModel(){
|
||||
_storageProvider = ProgramManager.Instance.StorageProvider ?? throw new ArgumentNullException(nameof(ProgramManager.Instance.StorageProvider));
|
||||
storageProvider = ProgramManager.Instance.StorageProvider ?? throw new ArgumentNullException(nameof(ProgramManager.Instance.StorageProvider));
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
|
||||
var version = Assembly
|
||||
.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||
?.InformationalVersion.Split('+')[0];
|
||||
CurrentVersion = $"v{version}";
|
||||
|
||||
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme ??[];
|
||||
faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme ??[];
|
||||
|
||||
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
|
||||
CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
|
||||
|
|
@ -264,6 +326,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
|
||||
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
|
||||
|
||||
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
|
||||
DownloadFinishedExecute = options.DownloadFinishedExecute;
|
||||
|
||||
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
|
||||
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
|
||||
|
||||
|
|
@ -289,6 +354,15 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
FlareSolverrPort = propsFlareSolverr.Port + "";
|
||||
}
|
||||
|
||||
var propsMitmFlareSolverr = options.FlareSolverrMitmProperties;
|
||||
|
||||
if (propsMitmFlareSolverr != null){
|
||||
MitmFlareSolverrUseSsl = propsMitmFlareSolverr.UseSsl;
|
||||
UseMitmFlareSolverr = propsMitmFlareSolverr.UseMitmProxy;
|
||||
MitmFlareSolverrHost = propsMitmFlareSolverr.Host + "";
|
||||
MitmFlareSolverrPort = propsMitmFlareSolverr.Port + "";
|
||||
}
|
||||
|
||||
ProxyEnabled = options.ProxyEnabled;
|
||||
ProxySocks = options.ProxySocks;
|
||||
ProxyHost = options.ProxyHost ?? "";
|
||||
|
|
@ -297,13 +371,19 @@ 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;
|
||||
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);
|
||||
DownloadToTempFolder = options.DownloadToTempFolder;
|
||||
|
|
@ -311,6 +391,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
|
||||
LogMode = options.LogMode;
|
||||
|
||||
TrayIconEnabled = options.TrayIconEnabled;
|
||||
StartMinimizedToTray = options.StartMinimizedToTray;
|
||||
MinimizeToTray = options.MinimizeToTray;
|
||||
MinimizeToTrayOnClose = options.MinimizeToTrayOnClose;
|
||||
|
||||
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
|
||||
CurrentAppTheme = theme ?? AppThemes[0];
|
||||
|
||||
|
|
@ -320,6 +405,16 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
History = options.History;
|
||||
|
||||
HistoryAutoRefreshModeHint = HistoryAutoRefreshMode switch{
|
||||
HistoryRefreshMode.DefaultAll =>
|
||||
"Refreshes the full history using the default method and includes all entries",
|
||||
HistoryRefreshMode.DefaultActive =>
|
||||
"Refreshes the history using the default method and includes only active entries",
|
||||
HistoryRefreshMode.FastNewReleases =>
|
||||
"Uses the faster refresh method, similar to the custom calendar, focusing on newly released items",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
settingsLoaded = true;
|
||||
}
|
||||
|
||||
|
|
@ -332,8 +427,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
|
||||
|
||||
settings.DownloadFinishedExecute = DownloadFinishedExecute;
|
||||
|
||||
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);
|
||||
|
|
@ -345,14 +444,17 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
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.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;
|
||||
|
|
@ -367,8 +469,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
settings.Theme = CurrentAppTheme?.Content + "";
|
||||
|
||||
if (_faTheme.CustomAccentColor != (Application.Current?.PlatformSettings?.GetColorValues().AccentColor1)){
|
||||
settings.AccentColor = _faTheme.CustomAccentColor.ToString();
|
||||
if (faTheme.CustomAccentColor != (Application.Current?.PlatformSettings?.GetColorValues().AccentColor1)){
|
||||
settings.AccentColor = faTheme.CustomAccentColor.ToString();
|
||||
} else{
|
||||
settings.AccentColor = string.Empty;
|
||||
}
|
||||
|
|
@ -403,11 +505,34 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
propsFlareSolverr.Port = 8989;
|
||||
}
|
||||
|
||||
var propsMitmFlareSolverr = new MitmProxyProperties();
|
||||
|
||||
propsMitmFlareSolverr.UseSsl = MitmFlareSolverrUseSsl;
|
||||
propsMitmFlareSolverr.UseMitmProxy = UseMitmFlareSolverr;
|
||||
propsMitmFlareSolverr.Host = MitmFlareSolverrHost;
|
||||
propsMitmFlareSolverr.UseSsl = MitmFlareSolverrUseSsl;
|
||||
|
||||
if (int.TryParse(MitmFlareSolverrPort, out var portNumberMitmFlare)){
|
||||
propsMitmFlareSolverr.Port = portNumberMitmFlare;
|
||||
} else{
|
||||
propsMitmFlareSolverr.Port = 8080;
|
||||
}
|
||||
|
||||
settings.FlareSolverrProperties = propsFlareSolverr;
|
||||
settings.FlareSolverrMitmProperties = propsMitmFlareSolverr;
|
||||
|
||||
settings.TrayIconEnabled = TrayIconEnabled;
|
||||
settings.StartMinimizedToTray = StartMinimizedToTray;
|
||||
settings.MinimizeToTray = MinimizeToTray;
|
||||
settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose;
|
||||
|
||||
settings.LogMode = LogMode;
|
||||
|
||||
CfgManager.WriteCrSettings();
|
||||
|
||||
if (!PersistQueue){
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
|
@ -429,7 +554,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
|
||||
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
|
||||
defaultPath: CfgManager.PathVIDEOS_DIR
|
||||
);
|
||||
}
|
||||
|
|
@ -441,18 +566,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
|
||||
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
|
||||
defaultPath: CfgManager.PathTEMP_DIR
|
||||
);
|
||||
}
|
||||
|
||||
private async Task OpenFolderDialogAsyncInternal(Action<string> pathSetter, Func<string> pathGetter, string defaultPath){
|
||||
if (_storageProvider == null){
|
||||
if (storageProvider == null){
|
||||
Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
|
||||
throw new InvalidOperationException("StorageProvider must be set before using the dialog.");
|
||||
}
|
||||
|
||||
var result = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions{
|
||||
var result = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions{
|
||||
Title = "Select Folder"
|
||||
});
|
||||
|
||||
|
|
@ -490,7 +615,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
BackgroundImagePath = path;
|
||||
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
|
||||
defaultPath: string.Empty
|
||||
);
|
||||
}
|
||||
|
|
@ -519,7 +644,35 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
|
||||
DownloadFinishedSoundPath = path;
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath,
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
|
||||
defaultPath: string.Empty
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download Finished Execute File
|
||||
|
||||
[RelayCommand]
|
||||
public void ClearFinishedExectuePath(){
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
|
||||
DownloadFinishedExecutePath = string.Empty;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task OpenFileDialogAsyncInternalFinishedExecute(){
|
||||
await OpenFileDialogAsyncInternal(
|
||||
title: "Select File",
|
||||
fileTypes: new List<FilePickerFileType>{
|
||||
new("All Files"){
|
||||
Patterns = new[]{ "*.*" }
|
||||
}
|
||||
},
|
||||
pathSetter: (path) => {
|
||||
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
|
||||
DownloadFinishedExecutePath = path;
|
||||
},
|
||||
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
|
||||
defaultPath: string.Empty
|
||||
);
|
||||
}
|
||||
|
|
@ -532,12 +685,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
Action<string> pathSetter,
|
||||
Func<string> pathGetter,
|
||||
string defaultPath){
|
||||
if (_storageProvider == null){
|
||||
if (storageProvider == null){
|
||||
Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
|
||||
throw new InvalidOperationException("StorageProvider must be set before using the dialog.");
|
||||
}
|
||||
|
||||
var result = await _storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions{
|
||||
var result = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions{
|
||||
Title = title,
|
||||
FileTypeFilter = fileTypes,
|
||||
AllowMultiple = false
|
||||
|
|
@ -556,13 +709,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
|
||||
if (value?.Content?.ToString() == "System"){
|
||||
_faTheme.PreferSystemTheme = true;
|
||||
faTheme.PreferSystemTheme = true;
|
||||
} else if (value?.Content?.ToString() == "Dark"){
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
faTheme.PreferSystemTheme = false;
|
||||
Application.Current?.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
} else{
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
|
||||
faTheme.PreferSystemTheme = false;
|
||||
Application.Current?.RequestedThemeVariant = ThemeVariant.Light;
|
||||
}
|
||||
|
||||
UpdateSettings();
|
||||
|
|
@ -570,7 +723,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
|
||||
partial void OnUseCustomAccentChanged(bool value){
|
||||
if (value){
|
||||
if (_faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){
|
||||
if (faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){
|
||||
CustomAccentColor = (Color)curColor;
|
||||
ListBoxColor = CustomAccentColor;
|
||||
|
||||
|
|
@ -601,9 +754,14 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
private void UpdateAppAccentColor(Color? color){
|
||||
_faTheme.CustomAccentColor = color;
|
||||
faTheme.CustomAccentColor = color;
|
||||
UpdateSettings();
|
||||
}
|
||||
partial void OnTrayIconEnabledChanged(bool value){
|
||||
((App)Application.Current!).SetTrayIconVisible(value);
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
|
||||
protected override void OnPropertyChanged(PropertyChangedEventArgs e){
|
||||
base.OnPropertyChanged(e);
|
||||
|
|
@ -613,12 +771,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
or nameof(ListBoxColor)
|
||||
or nameof(CurrentAppTheme)
|
||||
or nameof(UseCustomAccent)
|
||||
or nameof(LogMode)){
|
||||
or nameof(TrayIconEnabled)
|
||||
or nameof(LogMode)
|
||||
or nameof(PersistQueue)){
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateSettings();
|
||||
|
||||
HistoryAutoRefreshModeHint = HistoryAutoRefreshMode switch{
|
||||
HistoryRefreshMode.DefaultAll =>
|
||||
"Refreshes the full history using the default method and includes all entries",
|
||||
HistoryRefreshMode.DefaultActive =>
|
||||
"Refreshes the history using the default method and includes only active entries",
|
||||
HistoryRefreshMode.FastNewReleases =>
|
||||
"Uses the faster refresh method, similar to the custom calendar, focusing on newly released items",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
if (e.PropertyName is nameof(History)){
|
||||
if (CrunchyrollManager.Instance.CrunOptions.History){
|
||||
if (File.Exists(CfgManager.PathCrHistory)){
|
||||
|
|
@ -658,7 +828,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async void CheckIp(){
|
||||
public async Task CheckIp(){
|
||||
var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false));
|
||||
Console.Error.WriteLine("Your IP: " + result.ResponseContent);
|
||||
if (result.IsOk){
|
||||
|
|
@ -674,4 +844,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
|
|||
CfgManager.DisableLogMode();
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPersistQueueChanged(bool value){
|
||||
UpdateSettings();
|
||||
QueueManager.Instance.SaveQueueSnapshot();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
x:DataType="vm:AccountPageViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.AccountPageView">
|
||||
|
|
@ -13,14 +14,34 @@
|
|||
|
||||
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
|
||||
|
||||
<Grid Width="170" Height="170" Margin="20"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Top">
|
||||
|
||||
<!-- Profile Image -->
|
||||
<Image Width="170" Height="170" Margin="20"
|
||||
Source="{Binding ProfileImage}">
|
||||
<Image Source="{Binding ProfileImage}">
|
||||
<Image.Clip>
|
||||
<EllipseGeometry Rect="0,0,170,170" />
|
||||
</Image.Clip>
|
||||
</Image>
|
||||
|
||||
<!-- Switch Button (Overlay Bottom Right) -->
|
||||
<Button Width="42"
|
||||
Height="42"
|
||||
BorderThickness="0"
|
||||
CornerRadius="21"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,4,4"
|
||||
Command="{Binding OpenMultiProfileDialogCommand}"
|
||||
IsVisible="{Binding HasMultiProfile}"
|
||||
ToolTip.Tip="Switch Profile">
|
||||
|
||||
<controls:SymbolIcon Symbol="Switch" FontSize="30"/>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Profile Name -->
|
||||
<TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" />
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,10 @@
|
|||
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
|
||||
Content="Show Upcoming episodes" Margin="5 5 0 0">
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{Binding UpdateHistoryFromCalendar}"
|
||||
Content="Update History from Calendar" Margin="5 5 0 0">
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
|
@ -185,7 +189,8 @@
|
|||
<Grid HorizontalAlignment="Center">
|
||||
<Grid>
|
||||
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" />
|
||||
<Image HorizontalAlignment="Center" MaxHeight="150" Source="{Binding ImageBitmap}" />
|
||||
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="{Binding ImageBitmap}" />
|
||||
<Image HorizontalAlignment="Center" IsVisible="{Binding AnilistEpisode}" MaxHeight="150" Source="{Binding ImageBitmap}" />
|
||||
</Grid>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,20 @@
|
|||
</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"
|
||||
Margin="0 0 10 0 "
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -70,6 +70,48 @@
|
|||
</StackPanel>
|
||||
</ToggleButton>
|
||||
|
||||
|
||||
<StackPanel>
|
||||
<ToggleButton x:Name="DropdownButtonSearch" Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
|
||||
VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsSearchOpen, Mode=TwoWay}"
|
||||
IsEnabled="{Binding !ProgramManager.FetchingData}">
|
||||
<Grid>
|
||||
<StackPanel Orientation="Vertical">
|
||||
<controls:SymbolIcon Symbol="Zoom" FontSize="32" />
|
||||
<TextBlock Text="Search" HorizontalAlignment="Center" FontSize="12"></TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<Ellipse Width="10" Height="10"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,0,0,0"
|
||||
Fill="Orange"
|
||||
IsHitTestVisible="False"
|
||||
IsVisible="{Binding IsSearchActiveClosed}" />
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
<Popup IsLightDismissEnabled="True"
|
||||
IsOpen="{Binding IsSearchOpen, Mode=TwoWay}"
|
||||
Placement="BottomEdgeAlignedRight"
|
||||
PlacementTarget="{Binding ElementName=DropdownButtonSearch}">
|
||||
|
||||
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
|
||||
<StackPanel Orientation="Horizontal" Margin="10">
|
||||
|
||||
<TextBox x:Name="SearchBar" Width="160"
|
||||
Watermark="Search"
|
||||
Text="{Binding SearchInput, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<Button Content="✕" Margin="6,0,0,0"
|
||||
Command="{Binding ClearSearchCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Border>
|
||||
|
||||
|
||||
</Popup>
|
||||
</StackPanel>
|
||||
|
||||
<Rectangle Width="1" Height="50" Fill="Gray" Margin="10,0" />
|
||||
|
||||
<StackPanel Margin="10,0">
|
||||
|
|
@ -115,6 +157,7 @@
|
|||
<!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> -->
|
||||
</StackPanel>
|
||||
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
|
||||
|
||||
<Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100"
|
||||
|
|
@ -214,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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@
|
|||
IconSource="Library" />
|
||||
</ui:NavigationView.MenuItems>
|
||||
<ui:NavigationView.FooterMenuItems>
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Update" Tag="Update" Opacity="{Binding ProgramManager.OpacityButton}"
|
||||
<ui:NavigationViewItem Classes="SampleAppNav"
|
||||
Classes.redDot="{Binding ProgramManager.UpdateAvailable}"
|
||||
Content="Update" Tag="Update"
|
||||
IconSource="CloudDownload" Focusable="False" />
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Account" Tag="Account"
|
||||
IconSource="Contact" />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using CRD.Downloader;
|
||||
using CRD.Downloader.Crunchyroll;
|
||||
using CRD.Utils;
|
||||
|
|
@ -47,6 +48,8 @@ public partial class MainWindow : AppWindow{
|
|||
|
||||
private object selectedNavVieItem;
|
||||
|
||||
private ToastNotification? toast;
|
||||
|
||||
private const int TitleBarHeightAdjustment = 31;
|
||||
|
||||
private PixelPoint _restorePosition;
|
||||
|
|
@ -70,6 +73,7 @@ public partial class MainWindow : AppWindow{
|
|||
PositionChanged += OnPositionChanged;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
toast = this.FindControl<ToastNotification>("Toast");
|
||||
|
||||
//select first element as default
|
||||
var nv = this.FindControl<NavigationView>("NavView");
|
||||
|
|
@ -150,55 +154,48 @@ public partial class MainWindow : AppWindow{
|
|||
|
||||
|
||||
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
|
||||
var toastControl = this.FindControl<ToastNotification>("Toast");
|
||||
toastControl?.Show(message, type, durationInSeconds);
|
||||
Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds));
|
||||
}
|
||||
|
||||
|
||||
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
|
||||
if (sender is NavigationView navView){
|
||||
var selectedItem = navView.SelectedItem as NavigationViewItem;
|
||||
if (selectedItem != null){
|
||||
if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){
|
||||
switch (selectedItem.Tag){
|
||||
case "DownloadQueue":
|
||||
navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<DownloadsPageViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "AddDownload":
|
||||
navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<AddDownloadPageViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "Calendar":
|
||||
navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<CalendarPageViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "History":
|
||||
navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<HistoryPageViewModel>();
|
||||
navigationStack.Clear();
|
||||
navigationStack.Push(navView.Content);
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "Seasons":
|
||||
navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<UpcomingPageViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "Account":
|
||||
navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel));
|
||||
navView.Content = Activator.CreateInstance<AccountPageViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "Settings":
|
||||
var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel));
|
||||
var viewModel = Activator.CreateInstance<SettingsPageViewModel>();
|
||||
navView.Content = viewModel;
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
case "Update":
|
||||
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel));
|
||||
navView.Content = Activator.CreateInstance<UpdateViewModel>();
|
||||
selectedNavVieItem = selectedItem;
|
||||
break;
|
||||
default:
|
||||
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,7 +206,7 @@ public partial class MainWindow : AppWindow{
|
|||
if (settings != null){
|
||||
var screens = Screens.All;
|
||||
if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){
|
||||
var screen = screens[settings.ScreenIndex];
|
||||
// var screen = screens[settings.ScreenIndex];
|
||||
|
||||
// Restore the position first
|
||||
Position = new PixelPoint(settings.PosX, settings.PosY);
|
||||
|
|
|
|||
|
|
@ -161,6 +161,13 @@
|
|||
<Button Margin="0 0 5 10" FontStyle="Italic"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding OpenFolderDialogAsync}">
|
||||
<Button.ContextFlyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem Header="Clear Path"
|
||||
Command="{Binding ClearFolderPathCommand}" />
|
||||
</MenuFlyout>
|
||||
</Button.ContextFlyout>
|
||||
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="{Binding SelectedSeries.SeriesDownloadPath,
|
||||
Converter={StaticResource EmptyToDefault},
|
||||
|
|
@ -646,6 +653,13 @@
|
|||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).OpenFolderDialogAsync}"
|
||||
CommandParameter="{Binding .}">
|
||||
<Button.ContextFlyout>
|
||||
<MenuFlyout>
|
||||
<MenuItem Header="Clear Path"
|
||||
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).ClearFolderPathCommand}"
|
||||
CommandParameter="{Binding .}"/>
|
||||
</MenuFlyout>
|
||||
</Button.ContextFlyout>
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="{Binding SeasonDownloadPath,
|
||||
Converter={StaticResource EmptyToDefault},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils.Sonarr;
|
||||
using CRD.ViewModels;
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ public partial class SettingsPageView : UserControl{
|
|||
private void OnUnloaded(object? sender, RoutedEventArgs e){
|
||||
if (DataContext is SettingsPageViewModel viewModel){
|
||||
SonarrClient.Instance.RefreshSonarr();
|
||||
ProgramManager.Instance.StartRunners();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,10 +47,21 @@
|
|||
|
||||
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
|
||||
|
||||
<ToggleSwitch HorizontalAlignment="Right" Margin="0 0 10 0 " IsChecked="{Binding GhUpdatePrereleases}" OffContent="Prereleases" OnContent="Prereleases"></ToggleSwitch>
|
||||
|
||||
<Button Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding CheckForUpdate}">
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
|
||||
<controls:SymbolIcon Symbol="Refresh" FontSize="32" />
|
||||
<TextBlock Text="Check" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding StartUpdate}"
|
||||
IsEnabled="{Binding UpdateAvailable}">
|
||||
IsEnabled="{Binding ProgramManager.UpdateAvailable}">
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
|
||||
<controls:SymbolIcon Symbol="Download" FontSize="32" />
|
||||
<TextBlock Text="Update" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock>
|
||||
|
|
|
|||
|
|
@ -38,10 +38,15 @@
|
|||
<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}"
|
||||
|
|
@ -50,17 +55,17 @@
|
|||
</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}"
|
||||
|
|
|
|||
78
CRD/Views/Utils/ContentDialogMultiProfileSelectView.axaml
Normal file
78
CRD/Views/Utils/ContentDialogMultiProfileSelectView.axaml
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<ui:CustomContentDialog xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels.Utils"
|
||||
xmlns:structs="clr-namespace:CRD.Utils.Structs"
|
||||
xmlns:ui="clr-namespace:CRD.Utils.UI"
|
||||
x:DataType="vm:ContentDialogMultiProfileSelectViewModel"
|
||||
x:Class="CRD.Views.Utils.ContentDialogMultiProfileSelectView">
|
||||
|
||||
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ScrollViewer Grid.Row="3"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
|
||||
<ListBox ItemsSource="{Binding ProfileList}"
|
||||
SelectedItem="{Binding SelectedItem}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
|
||||
<!-- Horizontal layout -->
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem" x:DataType="structs:AccountProfile">
|
||||
<Setter Property="IsEnabled">
|
||||
<Setter.Value>
|
||||
<Binding Path="CanBeSelected"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBoxItem:disabled">
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
<ListBox.ItemTemplate >
|
||||
<DataTemplate>
|
||||
<StackPanel Width="220"
|
||||
Margin="0,0,12,0"
|
||||
IsEnabled="{Binding CanBeSelected}">
|
||||
|
||||
<Grid Width="170"
|
||||
Height="170"
|
||||
Margin="20"
|
||||
HorizontalAlignment="Center">
|
||||
|
||||
<Image Source="{Binding ProfileImage}">
|
||||
<Image.Clip>
|
||||
<EllipseGeometry Rect="0,0,170,170"/>
|
||||
</Image.Clip>
|
||||
</Image>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding ProfileName}"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="20"
|
||||
Margin="10"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
</ListBox>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
|
||||
</ui:CustomContentDialog>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using Avalonia.Controls;
|
||||
|
||||
namespace CRD.Views.Utils;
|
||||
|
||||
public partial class ContentDialogMultiProfileSelectView : UserControl{
|
||||
public ContentDialogMultiProfileSelectView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -63,6 +69,43 @@
|
|||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Auto History Refresh" Description="Automatically refresh your history at a set interval">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="8" Width="520">
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Refresh interval (minutes)" />
|
||||
<DockPanel LastChildFill="True">
|
||||
<controls:NumberBox Minimum="0"
|
||||
Maximum="1000000000"
|
||||
Value="{Binding HistoryAutoRefreshIntervalMinutes}"
|
||||
SpinButtonPlacementMode="Hidden"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</DockPanel>
|
||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
|
||||
Text="Set to 0 to disable automatic refresh." />
|
||||
</StackPanel>
|
||||
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Refresh mode" />
|
||||
<ComboBox ItemsSource="{Binding HistoryAutoRefreshModes}"
|
||||
DisplayMemberBinding="{Binding DisplayName}"
|
||||
SelectedValueBinding="{Binding value}"
|
||||
SelectedValue="{Binding HistoryAutoRefreshMode}" />
|
||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding HistoryAutoRefreshModeHint}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="Download Settings"
|
||||
|
|
@ -70,6 +113,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>
|
||||
|
|
@ -82,6 +131,12 @@
|
|||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Skip Episodes Missing Selected Languages" Description="Episodes will only be added to the queue if every selected audio and subtitle language is available">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding DownloadOnlyWithAllSelectedDubSub}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Max Download Speed"
|
||||
Description="Download in Kb/s - 0 is full speed">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
|
|
@ -242,50 +297,49 @@
|
|||
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
<!-- <Grid HorizontalAlignment="Right" Margin="0 5 0 0"> -->
|
||||
<!-- <Grid.ColumnDefinitions> -->
|
||||
<!-- <ColumnDefinition Width="Auto" /> -->
|
||||
<!-- <ColumnDefinition Width="150" /> -->
|
||||
<!-- </Grid.ColumnDefinitions> -->
|
||||
<!-- -->
|
||||
<!-- <Grid.RowDefinitions> -->
|
||||
<!-- <RowDefinition Height="Auto" /> -->
|
||||
<!-- <RowDefinition Height="Auto" /> -->
|
||||
<!-- </Grid.RowDefinitions> -->
|
||||
<!-- -->
|
||||
<!-- <TextBlock Text="Opacity" -->
|
||||
<!-- FontSize="15" -->
|
||||
<!-- Opacity="0.8" -->
|
||||
<!-- VerticalAlignment="Center" -->
|
||||
<!-- HorizontalAlignment="Right" -->
|
||||
<!-- Margin="0 0 5 10" -->
|
||||
<!-- Grid.Row="0" Grid.Column="0" /> -->
|
||||
<!-- <controls:NumberBox Minimum="0" Maximum="1" -->
|
||||
<!-- SmallChange="0.05" -->
|
||||
<!-- LargeChange="0.1" -->
|
||||
<!-- SimpleNumberFormat="F2" -->
|
||||
<!-- Value="{Binding BackgroundImageOpacity}" -->
|
||||
<!-- SpinButtonPlacementMode="Inline" -->
|
||||
<!-- HorizontalAlignment="Stretch" -->
|
||||
<!-- Margin="0 0 0 10" -->
|
||||
<!-- Grid.Row="0" Grid.Column="1" /> -->
|
||||
<!-- -->
|
||||
<!-- <TextBlock Text="Blur Radius" -->
|
||||
<!-- FontSize="15" -->
|
||||
<!-- Opacity="0.8" -->
|
||||
<!-- VerticalAlignment="Center" -->
|
||||
<!-- HorizontalAlignment="Right" -->
|
||||
<!-- Margin="0 0 5 0" -->
|
||||
<!-- Grid.Row="1" Grid.Column="0" /> -->
|
||||
<!-- <controls:NumberBox Minimum="0" Maximum="40" -->
|
||||
<!-- SmallChange="1" -->
|
||||
<!-- LargeChange="5" -->
|
||||
<!-- SimpleNumberFormat="F0" -->
|
||||
<!-- Value="{Binding BackgroundImageBlurRadius}" -->
|
||||
<!-- SpinButtonPlacementMode="Inline" -->
|
||||
<!-- HorizontalAlignment="Stretch" -->
|
||||
<!-- Grid.Row="1" Grid.Column="1" /> -->
|
||||
<!-- </Grid> -->
|
||||
</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.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<TextBlock 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">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<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}"> </CheckBox>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
|
|
@ -423,6 +477,65 @@
|
|||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
<controls:SettingsExpanderItem Content="Use Mitm Flare Solverr">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding UseMitmFlareSolverr}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Mitm Host" IsVisible="{Binding UseMitmFlareSolverr}">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding MitmFlareSolverrHost}" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Mitm Port" IsVisible="{Binding UseMitmFlareSolverr}">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding MitmFlareSolverrPort}" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Use SSL for Mitm" IsVisible="{Binding UseMitmFlareSolverr}">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding MitmFlareSolverrUseSsl}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="General Settings"
|
||||
IconSource="Bullets"
|
||||
Description="Adjust General settings"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Show tray icon">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding TrayIconEnabled}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Start minimized to tray" Description="Launches in the tray without opening a window">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding StartMinimizedToTray}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Minimize to tray" Description="Minimizing hides the window and removes it from the taskbar">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding MinimizeToTray}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Close to tray" Description="Clicking X hides the app in the tray instead of exiting">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding MinimizeToTrayOnClose}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="App Appearance"
|
||||
|
|
@ -675,7 +788,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>
|
||||
|
|
|
|||
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/Calendar_Custom_Settings.png
(Stored with Git LFS)
BIN
images/Calendar_Custom_Settings.png
(Stored with Git LFS)
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue