Compare commits

..

No commits in common. "master" and "v1.6.2" have entirely different histories.

126 changed files with 3097 additions and 7478 deletions

View file

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

View file

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

View file

@ -1,25 +1,15 @@
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 Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using System.Linq;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
namespace CRD;
public class App : Application{
private TrayIcon? trayIcon;
private bool exitRequested;
public partial class App : Application{
public override void Initialize(){
AvaloniaXamlLoader.Load(this);
}
@ -29,28 +19,13 @@ public class App : Application{
var isHeadless = Environment.GetCommandLineArgs().Contains("--headless");
var manager = ProgramManager.Instance;
QueueManager.Instance.RestorePersistedQueue();
if (!isHeadless){
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var mainWindow = new MainWindow{
desktop.MainWindow = new MainWindow{
DataContext = new MainWindowViewModel(manager),
};
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);
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
}
@ -60,129 +35,5 @@ public 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)

Binary file not shown.

View file

@ -10,8 +10,6 @@ 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;
@ -83,7 +81,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);
@ -236,16 +234,7 @@ 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 ?? [];
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");
}
}
var newEpisodes = newEpisodesBase.Data;
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){

View file

@ -1,18 +1,11 @@
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;
@ -22,12 +15,9 @@ 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;
@ -42,6 +32,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
HasPremium = false,
};
}
private string GetTokenFilePath(){
@ -58,10 +49,9 @@ 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;
}
}
@ -80,9 +70,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
public async Task AuthAnonymous(){
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
Subscription = new Subscription();
string uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{
{ "grant_type", "client_id" },
@ -133,15 +121,11 @@ 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);
}
}
private async Task AuthOld(AuthData data){
public async Task Auth(AuthData data){
string uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{
@ -178,13 +162,13 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
} else{
if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage("Login failed. Please check your username and password.", ToastType.Error, 5));
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", 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 {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error 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));
@ -195,64 +179,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
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");
await GetProfile();
}
}
@ -272,35 +199,29 @@ 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);
Subscription = subsc;
Profile.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 (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = expiration;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.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 (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
@ -315,27 +236,6 @@ 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();
}
}
}
}
}
@ -384,8 +284,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 {(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")}");
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");
}
if (response.IsOk){
@ -394,26 +294,18 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
await GetProfile();
}
} 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();

View file

@ -6,10 +6,8 @@ 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;
@ -75,102 +73,92 @@ public class CrEpisode(){
public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){
bool serieshasversions = true;
var episode = new CrunchyRollEpisodeData();
// Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
CrunchyRollEpisodeData 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){
crunInstance.History.MatchHistorySeriesWithSonarr(false);
await crunInstance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(false);
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile();
}
}
// initial key
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier)
? dlEpisode.Identifier.Split('|')[1]
: $"S{dlEpisode.SeasonNumber}";
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>()
};
episode.EpisodeAndLanguages = new EpisodeAndLanguage();
// Build Variants
if (dlEpisode.Versions != null){
foreach (var version in dlEpisode.Versions){
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
// 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);
}
}
} else{
// Episode didn't have versions, mark it as such to be logged.
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
// 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);
}
}
if (episode.EpisodeAndLanguages.Variants.Count == 0)
return episode;
var baseEp = episode.EpisodeAndLanguages.Variants[0].Item;
int specialIndex = 1;
int epIndex = 1;
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(baseEp.Episode)){
newKey = baseEp.Episode;
if (isSpecial && !string.IsNullOrEmpty(episode.EpisodeAndLanguages.Items[0].Episode)){
newKey = episode.EpisodeAndLanguages.Items[0].Episode ?? "SP" + (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id);
} else{
var epPart = baseEp.Episode ?? (baseEp.EpisodeNumber?.ToString() ?? "1");
newKey = isSpecial
? $"SP{epPart} {baseEp.Id}"
: $"E{epPart}";
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id) : episode.EpisodeAndLanguages.Items[0].Episode ?? epIndex + "")}";
}
episode.Key = newKey;
var seasonTitle =
episode.EpisodeAndLanguages.Variants
.Select(v => v.Item.SeasonTitle)
.FirstOrDefault(t => !DownloadQueueItemFactory.HasDubSuffix(t))
?? DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle);
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 title = baseEp.Title;
var seasonNumber = baseEp.GetSeasonNum();
var title = episode.EpisodeAndLanguages.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(episode.EpisodeAndLanguages.Items[0].Identifier) ?? episode.EpisodeAndLanguages.Items[0].SeasonNumber.ToString();
var languages = episode.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
var languages = episode.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{episode.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.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){
CrunchyEpMeta? retMeta = null;
// var ret = new Dictionary<string, CrunchyEpMeta>();
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var hslang = crunInstance.CrunOptions.Hslang;
var retMeta = new CrunchyEpMeta();
var selectedDubs = dubLang
.Where(d => episodeP.EpisodeAndLanguages.Variants.Any(v => v.Lang.CrLocale == d))
.ToList();
foreach (var v in episodeP.EpisodeAndLanguages.Variants){
var item = v.Item;
var lang = v.Lang;
for (int index = 0; index < episodeP.EpisodeAndLanguages.Items.Count; index++){
var item = episodeP.EpisodeAndLanguages.Items[index];
if (!dubLang.Contains(lang.CrLocale))
if (!dubLang.Contains(episodeP.EpisodeAndLanguages.Langs[index].CrLocale))
continue;
item.HideSeasonTitle = true;
@ -185,55 +173,67 @@ public class CrEpisode(){
item.SeriesTitle = "NO_TITLE";
}
item.SeqId = epNum;
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
if (retMeta == null){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeriesTitle));
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeasonTitle));
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 (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
);
if (episodeP.EpisodeAndLanguages.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episodeP.EpisodeAndLanguages.Langs.Any(epLang => epLang.CrLocale == language))
.ToList();
}
var playback = item.Playback;
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
epMetaData.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
));
}
return retMeta ?? new CrunchyEpMeta();
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;
}
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){

View file

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

View file

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

View file

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

View file

@ -6,10 +6,8 @@ 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;
@ -19,44 +17,32 @@ 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? all, List<string>? e){
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){
var ret = new Dictionary<string, CrunchyEpMeta>();
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
var hslang = crunInstance.CrunOptions.Hslang;
foreach (var kvp in eps){
var key = kvp.Key;
var episode = kvp.Value;
bool ShouldInclude(string epNum) =>
all is true || (e != null && e.Contains(epNum));
for (int index = 0; index < episode.Items.Count; index++){
var item = episode.Items[index];
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));
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));
continue;
}
// history override
var effectiveDubs = dubLang;
if (crunInstance.CrunOptions.History){
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
if (dubLangList.Count > 0)
effectiveDubs = dubLangList;
if (dubLangList.Count > 0){
dubLang = dubLangList;
}
}
if (!effectiveDubs.Contains(lang.CrLocale))
if (!dubLang.Contains(episode.Langs[index].CrLocale))
continue;
// season title fallbacks (same behavior)
item.HideSeasonTitle = true;
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
item.SeasonTitle = item.SeriesTitle;
@ -69,66 +55,66 @@ public class CrSeries{
item.SeriesTitle = "NO_TITLE";
}
// selection gate
if (!ShouldInclude(epNum))
continue;
var epNum = key.StartsWith('E') ? key[1..] : key;
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
// 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));
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
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))
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))
.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);
}
// playback preference
var playback = item.Playback;
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
item.Playback = item.StreamsLink;
}
}
// Add variant
ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
mediaId: item.Id,
lang: lang,
playback: playback,
versions: item.Versions,
isSubbed: item.IsSubbed,
isDubbed: item.IsDubbed
));
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);
}
}
// show ep
item.SeqId = epNum;
}
}
return ret;
}
@ -138,58 +124,64 @@ public class CrSeries{
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
if (parsedSeries?.Data == null){
if (parsedSeries == null || parsedSeries.Data == null){
Console.Error.WriteLine("Parse Data Invalid");
return null;
}
var episodes = new Dictionary<string, EpisodeAndLanguage>();
// var result = ParseSeriesResult(parsedSeries);
Dictionary<string, EpisodeAndLanguage> 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)
continue;
if (seasonData.Data != null){
foreach (var episode in seasonData.Data){
string episodeNum =
(episode.Episode != string.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}"))
?? string.Empty;
// Prepare the episode array
EpisodeAndLanguage item;
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.TryGetValue(episodeKey, out var item)){
item = new EpisodeAndLanguage(); // must have Variants
if (!episodes.ContainsKey(episodeKey)){
item = new EpisodeAndLanguage{
Items = new List<CrunchyEpisode>(),
Langs = new List<LanguageItem>()
};
episodes[episodeKey] = item;
} else{
item = episodes[episodeKey];
}
if (episode.Versions != null){
foreach (var version in episode.Versions){
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
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());
}
}
} else{
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang);
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());
}
}
}
}
}
@ -206,25 +198,22 @@ public class CrSeries{
int specialIndex = 1;
int epIndex = 1;
var keys = new List<string>(episodes.Keys);
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating.
foreach (var key in keys){
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+)?$");
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()}";
string newKey;
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + baseEp.Episode;
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id);
} else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
}
episodes.Remove(key);
int counter = 1;
@ -236,95 +225,63 @@ public class CrSeries{
episodes.Add(newKey, item);
if (isSpecial) specialIndex++;
else epIndex++;
if (isSpecial){
specialIndex++;
} else{
epIndex++;
}
}
var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList();
var normal = episodes.Where(e => e.Key.StartsWith("E")).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;
if (item.Variants.Count == 0)
continue;
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
var baseEp = item.Variants[0].Item;
var title = item.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
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();
var languages = item.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index)?.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.");
}
var crunchySeriesList = new CrunchySeriesList{
Data = sortedEpisodes
};
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
crunchySeriesList.Data = sortedEpisodes;
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
var key = kvp.Key;
var value = kvp.Value;
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();
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();
Languages.SortListByLangList(langList);
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = langList,
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,
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,
EpisodeType = EpisodeType.Episode,
Time = $"{seconds / 60}:{seconds % 60:D2}"
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
};
}).ToList();
@ -563,4 +520,5 @@ public class CrSeries{
return series;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,91 +0,0 @@
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
};
}
}

View file

@ -1,122 +0,0 @@
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;
}
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
@ -18,7 +18,6 @@ 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;
@ -81,10 +80,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private bool _muxToMp3;
[ObservableProperty]
private bool muxFonts;
[ObservableProperty]
private bool muxTypesettingFonts;
private bool _muxFonts;
[ObservableProperty]
private bool _muxCover;
@ -92,9 +88,6 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _syncTimings;
[ObservableProperty]
private bool _syncTimingsFullQualityFallback;
[ObservableProperty]
private bool _defaultSubSigns;
@ -162,28 +155,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private ComboBoxItem _selectedStreamEndpoint;
[ObservableProperty]
private bool firstEndpointVideo;
private bool _firstEndpointVideo;
[ObservableProperty]
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;
private bool _firstEndpointAudio;
[ObservableProperty]
private ComboBoxItem _SelectedStreamEndpointSecondary;
@ -201,19 +176,16 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private string _endpointDeviceType = "";
[ObservableProperty]
private bool endpointVideo;
private bool _endpointVideo;
[ObservableProperty]
private bool endpointAudio;
[ObservableProperty]
private bool endpointUseDefault;
private bool _endpointAudio;
[ObservableProperty]
private bool _isLoggingIn;
[ObservableProperty]
private bool endpointNotSignedWarning;
private bool _endpointNotSignedWarning;
[ObservableProperty]
private ComboBoxItem _selectedDefaultDubLang;
@ -274,13 +246,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public ObservableCollection<ListBoxItem> DubLangList{ get; } = [];
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [
new(){ Content = "none" },
];
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [
new(){ Content = "none" },
];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [];
public ObservableCollection<ListBoxItem> SubLangList{ get; } =[
@ -356,9 +324,6 @@ 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(){
@ -404,25 +369,14 @@ 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){
@ -487,10 +441,8 @@ 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;
@ -564,10 +516,8 @@ 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;
@ -602,11 +552,6 @@ 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();
@ -617,7 +562,6 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
endpointSettings.Device_type = EndpointDeviceType;
endpointSettings.Video = EndpointVideo;
endpointSettings.Audio = EndpointAudio;
endpointSettings.UseDefault = EndpointUseDefault;
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings;
@ -784,53 +728,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[RelayCommand]
public void ResetEndpointSettings(){
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
var ghAuth = Updater.Instance.GhAuthJson;
var ghAuthMobile = ghAuth.FirstOrDefault(e => e.Type.Equals("mobile"));
if (ghAuthMobile != null &&
!string.IsNullOrEmpty(ghAuthMobile.Authorization) &&
!string.IsNullOrEmpty(ghAuthMobile.VersionName) &&
Helpers.CompareClientVersions(ghAuthMobile.VersionName, Helpers.ExtractClientVersion(defaultSettings.UserAgent)) > 0){
defaultSettings.Authorization = ghAuthMobile.Authorization;
defaultSettings.UserAgent = $"Crunchyroll/{ghAuthMobile.VersionName} Android/16 okhttp/4.12.0";
}
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == ("android/phone")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
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();
EndpointAuthorization = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Authorization;
EndpointUserAgent = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.UserAgent;
EndpointDeviceName = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_name;
EndpointDeviceType = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_type;
}
[RelayCommand]
@ -851,7 +755,6 @@ 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(){

View file

@ -274,58 +274,6 @@
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>
@ -350,31 +298,25 @@
IsChecked="{Binding EndpointAudio}" />
</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 EndpointUseDefault}" />
</StackPanel>
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<StackPanel Margin="0,5">
<TextBlock Text="Authorization" />
<TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointAuthorization}" />
</StackPanel>
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<StackPanel Margin="0,5">
<TextBlock Text="User Agent" />
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointUserAgent}" />
</StackPanel>
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<StackPanel Margin="0,5">
<TextBlock Text="Device Type" />
<TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceType}" />
</StackPanel>
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<StackPanel Margin="0,5">
<TextBlock Text="Device Name" />
<TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceName}" />
@ -382,7 +324,6 @@
<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" />
@ -507,7 +448,7 @@
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Filename"
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\ &#10;Sonarr: ${sonarrSeriesTitle} ${sonarrSeriesReleaseYear} ${sonarrEpisodeTitle}">
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\">
<controls:SettingsExpanderItem.Footer>
<TextBox Name="FileNameTextBox" HorizontalAlignment="Left" MinWidth="250"
Text="{Binding FileName}" />
@ -580,18 +521,7 @@
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Include Fonts" Description="Includes the fonts in the mkv">
<controls:SettingsExpanderItem.Footer>
<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>
<CheckBox IsChecked="{Binding MuxFonts}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
@ -640,15 +570,6 @@
</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>

View file

@ -4,7 +4,6 @@ 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;
@ -59,59 +58,31 @@ public class History{
if (parsedSeries.Data != null){
var result = false;
foreach (var s in parsedSeries.Data){
var lang = string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang)
? crunInstance.DefaultLocale
: crunInstance.CrunOptions.HistoryLang;
var candidateIds = new List<string>();
var sId = s.Id;
if (s.Versions is{ Count: > 0 }){
candidateIds.AddRange(
s.Versions
.Where(v => v.Original == true && !string.IsNullOrWhiteSpace(v.Guid))
.OrderByDescending(v => v.Guid!.Length)
.Select(v => v.Guid!)
);
foreach (var sVersion in s.Versions.Where(sVersion => sVersion.Original == true)){
if (sVersion.Guid != null){
sId = sVersion.Guid;
}
if (!string.IsNullOrWhiteSpace(s.Id)){
candidateIds.Add(s.Id);
break;
}
}
candidateIds = candidateIds
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (!string.IsNullOrEmpty(seasonId) && sId != seasonId) continue;
if (!string.IsNullOrEmpty(seasonId) &&
!candidateIds.Contains(seasonId, StringComparer.OrdinalIgnoreCase)){
continue;
}
foreach (var candidateId in candidateIds){
try{
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(candidateId, lang, true);
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(sId, string.IsNullOrEmpty(crunInstance.CrunOptions.HistoryLang) ? crunInstance.DefaultLocale : crunInstance.CrunOptions.HistoryLang, true);
if (seasonData.Data is{ Count: > 0 }){
result = true;
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
break;
}
} catch{
// optional: log candidateId
}
await UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
}
}
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();
@ -158,129 +129,6 @@ 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>
@ -291,7 +139,10 @@ 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){
@ -332,7 +183,6 @@ 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();
@ -351,9 +201,6 @@ 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{
@ -361,7 +208,7 @@ public class History{
SeriesId = firstEpisode.GetSeriesId(),
Seasons =[],
HistorySeriesAddDate = DateTime.Now,
SeriesType = SeriesType.Unknown,
SeriesType = firstEpisode.GetSeriesType(),
SeriesStreamingService = StreamingService.Crunchyroll
};
crunInstance.HistoryList.Add(historySeries);
@ -370,10 +217,9 @@ public class History{
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
historySeries.Seasons.Add(newSeason);
historySeries.SeriesType = InferSeriesType(historySeries);
await RefreshSeriesData(seriesId, historySeries);
_ = historySeries.LoadImage();
historySeries.Seasons.Add(newSeason);
historySeries.UpdateNewEpisodes();
historySeries.Init();
newSeason.Init();
@ -407,10 +253,21 @@ public class History{
}
public HistoryEpisode? GetHistoryEpisode(string? seriesId, string? seasonId, string episodeId){
return CrunchyrollManager.Instance.HistoryList
.FirstOrDefault(series => series.SeriesId == seriesId)?
.Seasons.FirstOrDefault(season => season.SeasonId == seasonId)?
.EpisodesList.Find(e => e.EpisodeId == 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;
}
public (HistoryEpisode? historyEpisode, string downloadDirPath) GetHistoryEpisodeWithDownloadDir(string? seriesId, string? seasonId, string episodeId){
@ -545,7 +402,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 is SeriesType.Series or SeriesType.Movie){
if (historySeries.SeriesType == SeriesType.Series){
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();
@ -688,31 +545,6 @@ 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);
@ -725,39 +557,6 @@ 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{
@ -784,7 +583,6 @@ public class History{
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
EpisodeType = historySource.GetEpisodeType(),
EpisodeSeriesType = historySource.GetSeriesType(),
IsEpisodeAvailableOnStreamingService = true,
ThumbnailImageUrl = historySource.GetImageUrl(),
};

View file

@ -3,45 +3,61 @@ 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 sealed partial class ProgramManager : ObservableObject{
public partial class ProgramManager : ObservableObject{
#region Singelton
public static ProgramManager Instance{ get; } = new();
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
#region Observables
[ObservableProperty]
private bool fetchingData;
private bool _fetchingData;
[ObservableProperty]
private bool updateAvailable = true;
private bool _updateAvailable = true;
[ObservableProperty]
private bool finishedLoading;
private double _opacityButton = 0.4;
[ObservableProperty]
private bool navigationLock;
private bool _finishedLoading;
[ObservableProperty]
private bool _navigationLock;
#endregion
@ -54,17 +70,15 @@ public sealed partial class ProgramManager : ObservableObject{
#region Startup Param Variables
private Queue<Func<Task>> taskQueue = new Queue<Func<Task>>();
bool historyRefreshAdded;
bool historyRefreshAdded = false;
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()){
@ -92,12 +106,12 @@ public sealed partial class ProgramManager : ObservableObject{
}
}
_ = Init();
Init();
CleanUpOldUpdater();
}
internal async Task RefreshHistory(FilterType filterType){
private async Task RefreshHistory(FilterType filterType){
FetchingData = true;
@ -158,60 +172,12 @@ public sealed partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){
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,
@ -220,11 +186,11 @@ public sealed partial class ProgramManager : ObservableObject{
}
private async Task Init(){
try{
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
await Updater.Instance.CheckGhJsonAsync();
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
@ -247,13 +213,6 @@ public sealed partial class ProgramManager : ObservableObject{
FinishedLoading = true;
await WorkOffArgsTasks();
StartRunners(true);
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
NavigationLock = false;
}
}
@ -271,7 +230,7 @@ public sealed 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{
@ -280,6 +239,7 @@ public sealed partial class ProgramManager : ObservableObject{
}
}
private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -296,17 +256,4 @@ public sealed 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();
}
}

View file

@ -9,331 +9,499 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.CustomList;
using CRD.Utils.QueueManagement;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.ViewModels;
using CRD.Views;
using ReactiveUI;
namespace CRD.Downloader;
public sealed partial class QueueManager : ObservableObject{
public static QueueManager Instance{ get; } = new();
public partial class QueueManager : ObservableObject{
#region Download Variables
private readonly RefreshableObservableCollection<CrunchyEpMeta> queue = new();
public ReadOnlyObservableCollection<CrunchyEpMeta> Queue{ get; }
public RefreshableObservableCollection<CrunchyEpMeta> Queue = new RefreshableObservableCollection<CrunchyEpMeta>();
public ObservableCollection<DownloadItemModel> DownloadItemModels = new ObservableCollection<DownloadItemModel>();
private int activeDownloads;
private readonly DownloadItemModelCollection downloadItems = new();
public int ActiveDownloads => Volatile.Read(ref activeDownloads);
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;
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;
#endregion
public int ActiveDownloads{
get{
lock (downloadStartLock){
return activeOrStarting.Count;
}
}
}
public bool HasActiveDownloads => ActiveDownloads > 0;
[ObservableProperty]
private bool hasFailedItem;
private bool _hasFailedItem;
public event EventHandler? QueueStateChanged;
#region Singelton
private readonly CrunchyrollManager crunchyrollManager;
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
public QueueManager(){
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();
Queue.CollectionChanged += UpdateItemListOnRemove;
}
public void AddToQueue(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => {
if (!queue.Contains(item))
queue.Add(item);
});
public void IncrementDownloads(){
Interlocked.Increment(ref activeDownloads);
}
public void RemoveFromQueue(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => {
int index = queue.IndexOf(item);
if (index >= 0)
queue.RemoveAt(index);
});
public void ResetDownloads(){
Interlocked.Exchange(ref activeDownloads, 0);
}
public void ClearQueue(){
uiMutationQueue.Enqueue(() => queue.Clear());
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 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 oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
downloadItems.Remove(oldItem);
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");
}
}
} else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
downloadItems.Clear();
} else if (e.Action == NotifyCollectionChangedAction.Reset && Queue.Count == 0){
DownloadItemModels.Clear();
}
UpdateDownloadListItems();
}
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
uiMutationQueue.Enqueue(() => {
if (removeFromQueue){
int index = queue.IndexOf(item);
if (index >= 0)
queue.RemoveAt(index);
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();
} else{
queue.Refresh();
downloadItem = new DownloadItemModel(crunchyEpMeta);
_ = downloadItem.LoadImage();
DownloadItemModels.Add(downloadItem);
}
OnQueueStateChanged();
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);
});
}
public void UpdateDownloadListItems(){
downloadItems.SyncFromQueue(queue);
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
}
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);
_limit = newLimit;
}
}
}
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));
}
}

View file

@ -6,6 +6,9 @@ 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");
@ -13,12 +16,19 @@ 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...");

View file

@ -60,38 +60,20 @@
</ContentPresenter.Styles>
</ContentPresenter>
<Grid Width="28"
<Viewbox Name="IconBox"
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>

View file

@ -5,7 +5,7 @@ namespace CRD.Utils;
public class AudioPlayer{
private readonly Player _player;
private bool _isPlaying;
private bool _isPlaying = false;
public AudioPlayer(){
_player = new Player();

View file

@ -5,7 +5,6 @@ using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Http;
namespace CRD.Utils.DRM;
@ -13,7 +12,7 @@ public class Widevine{
private byte[] privateKey = new byte[0];
private byte[] identifierBlob = new byte[0];
public bool canDecrypt;
public bool canDecrypt = false;
#region Singelton

View file

@ -44,8 +44,6 @@ public enum SeriesType{
Artist,
[EnumMember(Value = "Series")]
Series,
[EnumMember(Value = "Movie")]
Movie,
[EnumMember(Value = "Unknown")]
Unknown
}
@ -278,28 +276,10 @@ 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,
Screenshot,
Headshot,
Poster,
ClearLogo,
}

View file

@ -21,7 +21,6 @@ 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;
@ -45,7 +44,7 @@ public class CfgManager{
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
private static StreamWriter logFile;
private static bool isLogModeEnabled;
private static bool isLogModeEnabled = false;
static CfgManager(){
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
@ -367,14 +366,4 @@ public class CfgManager{
return null;
}
}
public static void DeleteFileIfExists(string pathToFile){
try{
if (File.Exists(pathToFile)){
File.Delete(pathToFile);
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
}
}
}

View file

@ -2,14 +2,12 @@
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;
@ -17,7 +15,6 @@ using Newtonsoft.Json;
namespace CRD.Utils.HLS;
public class HlsDownloader{
private readonly CancellationToken _cancellationToken;
private Data _data = new();
private CrunchyEpMeta _currentEpMeta;
@ -25,24 +22,12 @@ 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;
@ -75,7 +60,6 @@ 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){
@ -155,8 +139,8 @@ public class HlsDownloader{
try{
var initDl = await DownloadPart(initSeg, 0, 0);
await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }), _cancellationToken);
await File.WriteAllBytesAsync(fn, initDl);
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }));
Console.WriteLine("Init part downloaded.");
} catch (Exception e){
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
@ -199,7 +183,7 @@ public class HlsDownloader{
}
try{
await Task.WhenAll(keyTasks.Values).WaitAsync(_cancellationToken);
await Task.WhenAll(keyTasks.Values);
} catch (Exception ex){
Console.Error.WriteLine($"Error downloading keys: {ex.Message}");
throw;
@ -214,7 +198,7 @@ public class HlsDownloader{
}
while (partTasks.Count > 0){
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values).WaitAsync(_cancellationToken);
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values);
int completedIndex = -1;
foreach (var task in partTasks){
if (task.Value == completedTask){
@ -248,7 +232,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, _cancellationToken);
await stream.WriteAsync(part, 0, part.Length);
}
writeSuccess = true;
@ -256,7 +240,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, _cancellationToken);
await Task.Delay(_data.WaitTime);
attempt++;
}
}
@ -282,16 +266,11 @@ 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(){
State = DownloadState.Downloading,
IsDownloading = true,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
@ -299,7 +278,7 @@ public class HlsDownloader{
};
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.IsDone){
if (!_currentEpMeta.DownloadProgress.Done){
foreach (var downloadItemDownloadedFile in _currentEpMeta.downloadedFiles){
try{
if (File.Exists(downloadItemDownloadedFile)){
@ -314,124 +293,26 @@ public class HlsDownloader{
return (Ok: false, _data.Parts);
}
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.Queue.Refresh();
await WaitWhilePausedAsync(_cancellationToken);
while (_currentEpMeta.Paused){
await Task.Delay(500);
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) ?? string.Empty, $"{sessionId}_temp");
string tempDir = Path.Combine(Path.GetDirectoryName(fn), $"{sessionId}_temp");
Directory.CreateDirectory(tempDir);
@ -445,7 +326,6 @@ public class HlsDownloader{
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
mergedParts = (int?)resumeData?.MergedParts ?? 0;
} catch{
// ignored
}
}
@ -454,18 +334,13 @@ public class HlsDownloader{
var semaphore = new SemaphoreSlim(_data.Threads);
var downloadTasks = new List<Task>();
int errorOccurred = 0;
bool errorOccurred = false;
var _lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
var cts = new CancellationTokenSource();
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);
@ -481,49 +356,111 @@ public class HlsDownloader{
int index = i;
downloadTasks.Add(DownloadBufferedSegmentAsync(index, segments, tempDir, resumeFile, totalSeg, mergedParts, semaphore, cts, token,
() => Interlocked.Exchange(ref errorOccurred, 1),
() => Volatile.Read(ref _lastUiUpdate),
value => Interlocked.Exchange(ref _lastUiUpdate, value)));
downloadTasks.Add(Task.Run(async () => {
try{
token.ThrowIfCancellationRequested();
var segment = new Segment{
Uri = ObjectUtilities.GetMemberValue(segments[index], "uri"),
Key = ObjectUtilities.GetMemberValue(segments[index], "key"),
ByteRange = ObjectUtilities.GetMemberValue(segments[index], "byteRange")
};
var data = await DownloadPart(segment, index, _data.Offset);
string tempFile = Path.Combine(tempDir, $"part_{index:D6}.tmp");
await File.WriteAllBytesAsync(tempFile, data);
int currentDownloaded = Directory.GetFiles(tempDir, "part_*.tmp").Length;
lock (_resumeLock){
File.WriteAllText(resumeFile, JsonConvert.SerializeObject(new{
DownloadedParts = currentDownloaded,
MergedParts = mergedParts,
Total = totalSeg
}));
}
if (DateTimeOffset.Now.ToUnixTimeMilliseconds() - _lastUiUpdate > 500){
var dataLog = GetDownloadInfo(
_lastUiUpdate,
currentDownloaded,
totalSeg,
_data.BytesDownloaded,
_data.TotalBytes
);
_data.BytesDownloaded = 0;
_lastUiUpdate = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var downloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
? $"{dataLog.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{dataLog.DownloadSpeedBytes / 1000000.0:F2} MB/s";
Console.WriteLine($"{currentDownloaded}/{totalSeg} [{dataLog.Percent}%] Speed: {downloadSpeed} ETA: {FormatTime(dataLog.Time)}");
_currentEpMeta.DownloadProgress = new DownloadProgress{
IsDownloading = true,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
};
}
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cts.Cancel();
return;
}
QueueManager.Instance.Queue.Refresh();
while (_currentEpMeta.Paused){
await Task.Delay(500);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
cts.Cancel();
return;
}
}
} catch (Exception ex){
Console.Error.WriteLine($"Error downloading part {index}: {ex.Message}");
errorOccurred = true;
cts.Cancel();
} finally{
semaphore.Release();
}
}, token));
}
try{
await Task.WhenAll(downloadTasks);
} catch (OperationCanceledException){
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
CleanupBufferedArtifacts();
if (!_currentEpMeta.DownloadProgress.Done){
CleanupNewDownloadMethod(tempDir, resumeFile, true);
}
} else{
Console.Error.WriteLine("Download cancelled due to error.");
CleanupBufferedArtifacts(false);
}
return (false, _data.Parts);
}
} finally{
cts.Dispose();
}
if (Volatile.Read(ref errorOccurred) == 1){
CleanupBufferedArtifacts(false);
if (errorOccurred)
return (false, _data.Parts);
}
using (var output = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
for (int i = mergedParts; i < segments.Count; i++){
if (token.IsCancellationRequested){
CleanupBufferedArtifacts();
if (token.IsCancellationRequested)
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, token);
await output.WriteAsync(data, 0, data.Length, token);
byte[] data = await File.ReadAllBytesAsync(tempFile);
await output.WriteAsync(data, 0, data.Length);
mergedParts++;
@ -536,24 +473,19 @@ 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{
State = DownloadState.Processing,
IsDownloading = true,
Percent = dataLog.Percent,
Time = dataLog.Time,
DownloadSpeedBytes = dataLog.DownloadSpeedBytes,
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
};
QueueManager.Instance.RefreshQueue();
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
CleanupBufferedArtifacts();
if (!_currentEpMeta.DownloadProgress.Done){
CleanupNewDownloadMethod(tempDir, resumeFile, true);
}
return (false, _data.Parts);
}
}
@ -567,7 +499,14 @@ public class HlsDownloader{
private void CleanupNewDownloadMethod(string tempDir, string resumeFile, bool cleanAll = false){
if (cleanAll){
CleanupDownloadedFiles();
// 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}");
}
}
}
// Delete temp directory
@ -622,7 +561,6 @@ 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;
@ -630,7 +568,7 @@ public class HlsDownloader{
byte[]? part;
if (seg.Key != null){
var decipher = await GetKey(seg.Key, p, segOffset);
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries);
var partContent = part;
using (decipher){
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
@ -641,7 +579,7 @@ public class HlsDownloader{
Interlocked.Add(ref _data.TotalBytes, dec.Length);
}
} else{
part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries, _cancellationToken);
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries);
dec = part;
if (dec != null){
Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
@ -704,7 +642,7 @@ public class HlsDownloader{
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
if (!_data.Keys.ContainsKey(kUri)){
try{
var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries, _cancellationToken);
var rkey = await GetData(segIndex, kUri, new Dictionary<string, string>(), segOffset, true, _data.Timeout, _data.Retries);
if (rkey == null || rkey.Length != 16){
throw new Exception("Key not fully downloaded or is incorrect.");
}
@ -720,8 +658,7 @@ public class HlsDownloader{
return _data.Keys[kUri];
}
public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount, CancellationToken cancellationToken){
cancellationToken.ThrowIfCancellationRequested();
public async Task<byte[]?> GetData(int partIndex, string uri, IDictionary<string, string> headers, int segOffset, bool isKey, int timeout, int retryCount){
// Handle local file URI
if (uri.StartsWith("file://")){
string path = new Uri(uri).LocalPath;
@ -730,8 +667,8 @@ public class HlsDownloader{
// Setup request headers
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
if (byteRange != null){
request.Headers.Range = new RangeHeaderValue(byteRange.Offset, byteRange.Offset + byteRange.Length - 1);
foreach (var header in headers){
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
// Set default user-agent if not provided
@ -739,18 +676,17 @@ public class HlsDownloader{
request.Headers.Add("User-Agent", ApiUrls.FirefoxUserAgent);
}
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount, cancellationToken);
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount);
}
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount, CancellationToken cancellationToken){
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){
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, cancellationToken);
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
return await ReadContentAsByteArrayAsync(response.Content, cancellationToken);
return await ReadContentAsByteArrayAsync(response.Content);
} catch (Exception ex) when (ex is HttpRequestException or IOException){
// Log retry attempts
string partType = isKey ? "Key" : "Part";
@ -760,7 +696,7 @@ public class HlsDownloader{
if (attempt == retryCount)
throw; // rethrow after last retry
await Task.Delay(_data.WaitTime, cancellationToken);
await Task.Delay(_data.WaitTime);
} catch (Exception ex){
Console.Error.WriteLine($"Unexpected exception at part {partIndex + 1 + segOffset}:");
Console.Error.WriteLine($"\tType: {ex.GetType()}");
@ -773,14 +709,14 @@ public class HlsDownloader{
return null; // Should not reach here
}
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content, CancellationToken cancellationToken){
private async Task<byte[]> ReadContentAsByteArrayAsync(HttpContent content){
using (var memoryStream = new MemoryStream())
using (var contentStream = await content.ReadAsStreamAsync(cancellationToken))
using (var contentStream = await content.ReadAsStreamAsync())
using (var throttledStream = new ThrottledStream(contentStream)){
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await throttledStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0){
await memoryStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
while ((bytesRead = await throttledStream.ReadAsync(buffer, 0, buffer.Length)) > 0){
await memoryStream.WriteAsync(buffer, 0, bytesRead);
}
return memoryStream.ToArray();
@ -817,7 +753,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();
@ -861,6 +797,13 @@ 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{

View file

@ -7,7 +7,6 @@ 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;
@ -20,43 +19,16 @@ 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();
@ -87,7 +59,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,
@ -218,7 +190,80 @@ public class Helpers{
}
}
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command, CancellationToken cancellationToken = default){
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){
try{
using (var process = new Process()){
process.StartInfo.FileName = bin;
@ -249,17 +294,7 @@ public class Helpers{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await using var registration = cancellationToken.Register(() => {
try{
if (!process.HasExited){
process.Kill(true);
}
} catch{
// ignored
}
});
await process.WaitForExitAsync(cancellationToken);
await process.WaitForExitAsync();
bool isSuccess = process.ExitCode == 0;
@ -271,318 +306,211 @@ public class Helpers{
}
}
public static bool DeleteFile(string filePath, int maxRetries = 5, int delayMs = 150){
public static void DeleteFile(string filePath){
if (string.IsNullOrEmpty(filePath)){
return false;
return;
}
for (int attempt = 0; attempt < maxRetries; attempt++){
try{
if (!File.Exists(filePath)){
return true;
}
if (File.Exists(filePath)){
File.Delete(filePath);
return true;
} catch (Exception ex) when (attempt < maxRetries - 1 && (ex is IOException || ex is UnauthorizedAccessException)){
Thread.Sleep(delayMs * (attempt + 1));
}
} catch (Exception ex){
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
return false;
}
}
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: file remained locked after {maxRetries} attempts.");
return false;
}
public static string GetAvailableDestinationPath(string destinationPath){
if (!File.Exists(destinationPath)){
return destinationPath;
}
var directory = Path.GetDirectoryName(destinationPath) ?? string.Empty;
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(destinationPath);
var extension = Path.GetExtension(destinationPath);
var counter = 1;
string candidatePath;
do{
candidatePath = Path.Combine(directory, $"{fileNameWithoutExtension}({counter}){extension}");
counter++;
} while (File.Exists(candidatePath));
return candidatePath;
}
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
Process? process = null;
DataReceivedEventHandler? outputHandler = null;
DataReceivedEventHandler? errorHandler = null;
try{
process = new Process{
StartInfo = new ProcessStartInfo{
WorkingDirectory = workingDir,
FileName = bin,
Arguments = command,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
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;
outputHandler = (_, e) => {
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
errorHandler = (_, e) => {
process.ErrorDataReceived += (sender, 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();
return (IsOk: process.ExitCode == 0, ErrorCode: process.ExitCode);
bool isSuccess = process.ExitCode == 0;
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
return (IsOk: false, ErrorCode: -1);
} finally{
if (process != null){
if (outputHandler != null){
process.OutputDataReceived -= outputHandler;
}
if (errorHandler != null){
process.ErrorDataReceived -= errorHandler;
}
process.Dispose();
}
}
}
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
if (preset.Crf is -1)
return [];
var q = preset.Crf.ToString();
private static string GetQualityOption(VideoPreset preset){
return preset.Codec switch{
"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] : []
"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
};
}
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 ext = Path.GetExtension(inputFilePath);
string dir = Path.GetDirectoryName(inputFilePath)!;
string name = Path.GetFileNameWithoutExtension(inputFilePath);
string outputExtension = Path.GetExtension(inputFilePath);
string directory = Path.GetDirectoryName(inputFilePath);
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath);
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
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);
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
if (totalDuration == null){
Console.Error.WriteLine("Unable to retrieve input file duration.");
} else{
Console.WriteLine($"Total Duration: {totalDuration}");
}
var args = new List<string>{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
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;
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
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
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);
}
}
Console.Error.WriteLine("FFMPEG task was canceled");
return (false, -2);
}
bool success = exitCode == 0;
if (success){
File.Delete(inputFilePath);
File.Move(tempOutput, inputFilePath);
} else{
if (File.Exists(tempOutput)){
File.Delete(tempOutput);
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine("Command:");
Console.Error.WriteLine(commandString);
}
return (success, exitCode);
} catch (Exception ex){
Console.Error.WriteLine(ex);
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
};
foreach (var arg in args)
process.StartInfo.ArgumentList.Add(arg);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
// 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{
proc.Kill(true);
} catch{
// ignored
}
});
try{
await process.WaitForExitAsync(token);
} catch (OperationCanceledException){
using var reg = data?.Cts.Token.Register(() => {
try{
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
}
});
throw;
try{
await process.WaitForExitAsync(data.Cts.Token);
} catch (OperationCanceledException){
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
// ignored
}
}
await Task.WhenAll(stdoutTask, stderrTask);
return process.ExitCode;
return (IsOk: false, ErrorCode: -2);
}
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{
@ -983,8 +911,6 @@ public class Helpers{
if (result == ContentDialogResult.Primary){
timer.Stop();
}
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
ShutdownLock.Release();
}
@ -1041,22 +967,4 @@ 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;
}
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
@ -8,52 +7,35 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using Newtonsoft.Json;
namespace CRD.Utils.Http;
namespace CRD.Utils;
public class FlareSolverrClient{
private readonly HttpClient httpClient;
private readonly HttpClient _httpClient;
private FlareSolverrProperties? flareProperties;
private readonly MitmProxyProperties? mitmProperties;
private FlareSolverrProperties properties;
private string flaresolverrUrl = "http://localhost:8191";
private readonly string mitmProxyUrl = "localhost:8080";
private const string HeaderToken = "$$headers[]";
private const string PostToken = "$$post";
public FlareSolverrClient(){
flareProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
mitmProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrMitmProperties;
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null) properties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
if (flareProperties != null){
flaresolverrUrl = $"http{(flareProperties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(flareProperties.Host) ? flareProperties.Host : "localhost")}:{flareProperties.Port}";
if (properties != null){
flaresolverrUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}";
}
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");
_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 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)> SendViaFlareSolverrAsync(HttpRequestMessage request,List<Cookie> 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,
@ -89,10 +71,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}", [], string.Empty);
return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", []);
}
string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync();
@ -101,10 +83,10 @@ public class FlareSolverrClient{
var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent);
if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){
return (IsOk: true, ResponseContent: flareSolverrResult.Solution?.Response ?? string.Empty, flareSolverrResult.Solution?.Cookies ?? [], flareSolverrResult.Solution?.UserAgent ?? string.Empty);
return (IsOk: true, ResponseContent: flareSolverrResult.Solution.Response, flareSolverrResult.Solution.cookies);
} else{
Console.Error.WriteLine($"Flare Solverr Failed \n Response: {flareSolverrResponseContent}");
return (IsOk: false, ResponseContent: flareSolverrResponseContent, [], string.Empty);
return (IsOk: false, ResponseContent: flareSolverrResponseContent, []);
}
}
@ -133,179 +115,23 @@ 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{
[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 string Url{ get; set; }
public string Status{ get; set; }
public List<Cookie> cookies{ get; set; }
public string Response{ get; set; }
}
public class FlareSolverrProperties(){
public bool UseFlareSolverr{ get; set; }
public string? Host{ get; set; } = "localhost";
public string? Host{ get; set; }
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; }
}

View file

@ -1,30 +1,48 @@
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.Http;
namespace CRD.Utils;
public class HttpClientReq{
public static HttpClientReq Instance{ get; } = new();
#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
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;
HttpClientHandler handler = new HttpClientHandler();
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,
@ -56,7 +74,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/146.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/129.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");
@ -66,7 +84,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();
}
}
@ -112,37 +130,15 @@ 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,
bool allowChallengeBypass = true){
public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null){
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 (allowChallengeBypass && ChallengeDetector.IsClearanceRequired(response)){
if (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();
@ -153,16 +149,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, []);
@ -170,7 +166,7 @@ public class HttpClientReq{
content = flareSolverrResponses.ResponseContent;
return (flareSolverrResponses.IsOk, ResponseContent: content, error: "");
return (IsOk: flareSolverrResponses.IsOk, ResponseContent: content, error: "");
} catch (Exception e){
if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
@ -239,7 +235,7 @@ public class HttpClientReq{
}
if (cookieStore.TryGetValue(domain, out var cookies)){
var cookie = cookies.FirstOrDefault(c => c.Name == cookieName);
var cookie = cookies.Cast<Cookie>().FirstOrDefault(c => c.Name == cookieName);
return cookie?.Value;
}
@ -264,71 +260,6 @@ 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)){
@ -365,7 +296,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 MultiProfile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string Profiles => (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";

View file

@ -1,32 +0,0 @@
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);
}
}

View file

@ -1,252 +0,0 @@
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);
}
}

View file

@ -1,244 +0,0 @@
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");
}
}
}

View file

@ -1,516 +0,0 @@
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();
}

View file

@ -0,0 +1,248 @@
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; }
}

View file

@ -1,34 +1,415 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CRD.Utils.Muxing.Commands;
using CRD.Utils.Muxing.Structs;
using System.Xml;
using CRD.Utils.Files;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing;
public class Merger{
public MergerOptions Options;
public MergerOptions options;
public Merger(MergerOptions options){
Options = options;
this.options = options;
if (Options.VideoTitle is{ Length: > 0 }){
Options.VideoTitle = Options.VideoTitle.Replace("\"", "'");
if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){
this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'");
}
}
public string FFmpeg(){
return new FFmpegCommandBuilder(Options).Build();
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++;
}
}
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(){
return new MkvMergeCommandBuilder(Options).Build();
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);
}
public async Task<bool> Merge(string type, string bin, CancellationToken cancellationToken = default){
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){
string command = type switch{
"ffmpeg" => FFmpeg(),
"mkvmerge" => MkvMerge(),
@ -41,7 +422,7 @@ public class Merger{
}
Console.WriteLine($"[{type}] Started merging");
var result = await Helpers.ExecuteCommandAsync(bin, command, cancellationToken);
var result = await Helpers.ExecuteCommandAsync(type, bin, command);
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
@ -59,21 +440,111 @@ public class Merger{
public void CleanUp(){
// Combine all media file lists and iterate through them
var allMediaFiles = Options.OnlyAudio.Concat(Options.OnlyVid).Concat(Options.VideoAndAudio)
var allMediaFiles = options.OnlyAudio.Concat(options.OnlyVid)
.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; }
}

View file

@ -1,34 +0,0 @@
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; }
}

View file

@ -1,12 +0,0 @@
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; }
}

View file

@ -1,39 +0,0 @@
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; }
}

View file

@ -1,7 +0,0 @@
namespace CRD.Utils.Muxing.Structs;
public class ParsedFont{
public string Name{ get; set; }
public string Path{ get; set; }
public string Mime{ get; set; }
}

View file

@ -1,13 +0,0 @@
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;
}

View file

@ -1,142 +0,0 @@
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;
}
}

View file

@ -1,11 +1,10 @@
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;
@ -14,7 +13,7 @@ using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace CRD.Utils.Muxing.Syncing;
namespace CRD.Utils.Muxing;
public class SyncingHelper{
public static async Task<(bool IsOk, int ErrorCode, double frameRate)> ExtractFrames(string videoPath, string outputDir, double offset, double duration){
@ -213,7 +212,7 @@ public class SyncingHelper{
Pixels = GetPixelsArray(f.FilePath)
}).ToList();
var delay = double.NaN;
var delay = 0.0;
foreach (var baseFrame in baseFrames){
var baseFramePixels = GetPixelsArray(baseFrame.FilePath);

View file

@ -237,6 +237,7 @@ public class ToM3u8Class{
m3u8Attributes["PROGRAM-ID"] = 1;
if (ObjectUtilities.GetMemberValue(item.attributes,"codecs") != null){
m3u8Attributes["CODECS"] = item.attributes.codecs;
}
@ -441,26 +442,21 @@ 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);
playlist.discontinuitySequence =
timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
if (playlist.segments == null) continue;
var segments = playlist.segments as List<dynamic>;
if (segments == null) continue;
for (int i = 0; i < segments.Count; i++){
segments[i].number = i;
for (int i = 0; i < playlist.segments.Count; i++){
playlist.segments[i].number = i;
}
}
}
public static List<int> FindIndexes(List<dynamic> list, string key){
var indexes = new List<int>(list.Count);
var indexes = new List<int>();
for (int i = 0; i < list.Count; i++){
if (list[i] is IDictionary<string, object?> dict &&
dict.TryGetValue(key, out var value) &&
value != null){
var expandoDict = list[i] as IDictionary<string, object>;
if (expandoDict != null && expandoDict.ContainsKey(key) && expandoDict[key] != null){
indexes.Add(i);
}
}
@ -468,50 +464,33 @@ 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))
return playlist;
if (!sidxMapping.TryGetValue(sidxKey, out var sidxMatch) || sidxMatch == null)
return playlist;
SegmentBase.AddSidxSegmentsToPlaylist(
playlist,
sidxMatch?.sidx,
ObjectUtilities.GetMemberValue(playlist.sidx, "resolvedUri"));
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);
}
}
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;
}
foreach (var playlist in playlists){
AddSidxSegmentsToPlaylist(playlist, sidxMapping);
for (int i = 0; i < playlists.Count; i++){
playlists[i] = AddSidxSegmentsToPlaylist(playlists[i], sidxMapping);
}
return playlists;
}
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)}";
public static string GenerateSidxKey(dynamic sidx){
return sidx != null ? $"{sidx.uri}-{UrlType.ByteRangeToString(sidx.byterange)}" : null;
}
}

View file

@ -1,18 +1,14 @@
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.Http;
using CRD.Utils.Parser;
using CRD.Utils.Parser.Utils;
using CRD.Utils.Structs;
namespace CRD.Utils.Parser;
namespace CRD.Utils;
public class Segment{
public string uri{ get; set; }
@ -33,7 +29,6 @@ public class Map{
public class PlaylistItem{
public string? pssh{ get; set; }
public List<ContentKey> encryptionKeys{ get; set; } =[];
public int bandwidth{ get; set; }
public List<Segment> segments{ get; set; }
@ -41,13 +36,13 @@ public class PlaylistItem{
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{
@ -74,12 +69,12 @@ public class ServerData{
public List<VideoPlaylist>? video{ get; set; } =[];
}
public static class MpdParser{
public async static Task<MPDParsed> Parse(string manifest, LanguageItem? language, string? url){
public static class MPDParser{
public static 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();
}
@ -89,295 +84,90 @@ public static class MpdParser{
foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){
foreach (var playlist in item.playlists){
var uri = new Uri(playlist.resolvedUri);
var host = uri.Host;
var host = new Uri(playlist.resolvedUri).Host;
EnsureHostEntryExists(ret, host);
List<dynamic> segments = playlist.segments;
List<Segment>? segmentsFromSidx = null;
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);
}
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
throw new NotImplementedException();
}
var foundLanguage =
Languages.FindLang(
Languages.languages.FirstOrDefault(a => a.CrLocale == item.language)?.CrLocale ?? "unknown"
);
LanguageItem? audioLang =
item.language != null
? foundLanguage
: (language ?? foundLanguage);
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 pItem = new AudioPlaylist{
bandwidth = playlist.attributes.BANDWIDTH,
audioSamplingRate = ObjectUtilities.GetMemberValue(playlist.attributes ,"AUDIOSAMPLINGRATE") ?? 0,
language = audioLang,
@default = item.@default,
segments = segmentsFromSidx ?? ConvertSegments(segments)
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()
};
pItem.pssh = ExtractWidevinePssh(playlist);
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
ret.Data[host].audio?.Add(pItem);
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);
}
}
foreach (var playlist in parsed.playlists){
var uri = new Uri(playlist.resolvedUri);
var host = uri.Host;
var host = new Uri(playlist.resolvedUri).Host;
EnsureHostEntryExists(ret, host);
List<dynamic> segments = playlist.segments;
List<Segment>? segmentsFromSidx = null;
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);
}
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
throw new NotImplementedException();
}
dynamic resolution =
ObjectUtilities.GetMemberValue(playlist.attributes, "RESOLUTION") ?? new Quality();
dynamic resolution = ObjectUtilities.GetMemberValue(playlist.attributes,"RESOLUTION");
resolution = resolution != null ? resolution : new Quality();
var pItem = new VideoPlaylist{
bandwidth = playlist.attributes.BANDWIDTH,
codecs = ObjectUtilities.GetMemberValue(playlist.attributes, "CODECS") ?? "",
quality = new Quality{
height = resolution.height,
width = resolution.width
},
segments = segmentsFromSidx ?? ConvertSegments(segments)
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()
};
pItem.pssh = ExtractWidevinePssh(playlist);
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
ret.Data[host].video?.Add(pItem);
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);
}
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 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; }
public static string ArrayBufferToBase64(byte[] buffer){
return Convert.ToBase64String(buffer);
}
}

View file

@ -20,39 +20,28 @@ public class InheritAttributes{
var keySystemInfo = new ExpandoObject() as IDictionary<string, object>;
foreach (var node in contentProtectionNodes){
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;
dynamic attributes = ParseAttribute.ParseAttributes(node); // Assume this returns a dictionary
var testAttributes = attributes as IDictionary<string, object>;
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;
if (!string.IsNullOrEmpty(pssh))
info.pssh = DecodeB64ToUint8Array(pssh);
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
}
}
// Instead of using a dictionary key, add the key system directly as a member of the ExpandoObject
keySystemInfo[keySystem] = info;
}
}
}
return keySystemInfo;
}
@ -93,6 +82,8 @@ 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
//
@ -188,14 +179,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;
@ -203,14 +194,10 @@ public class InheritAttributes{
segmentInfo.list = segmentList != null
? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, 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;
segmentInfo.baseInfo = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase), new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) : 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){
@ -314,22 +301,21 @@ public class InheritAttributes{
attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes);
var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault();
var captionServices = ParseCaptionServiceMetadata(accessibility != null ? ParseAttribute.ParseAttributes(accessibility) : null);
var captionServices = ParseCaptionServiceMetadata(ParseAttribute.ParseAttributes(accessibility));
if (captionServices != null){
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices });
}
XmlElement? label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
if (label is{ ChildNodes.Count: > 0 }){
var labelVal = label.ChildNodes[0]?.Value?.Trim();
XmlElement label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
if (label != null && label.ChildNodes.Count > 0){
var labelVal = label.ChildNodes[0].ToString().Trim();
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal });
}
var nodes = XMLUtils.FindChildren(adaptationSet, "ContentProtection");
var contentProtection = GenerateKeySystemInformation(nodes);
var contentProtection = GenerateKeySystemInformation(XMLUtils.FindChildren(adaptationSet, "ContentProtection"));
var tempTestContentProtection = contentProtection as IDictionary<string, Object>;
if (tempTestContentProtection is{ Count: > 0 }){
if (tempTestContentProtection != null && tempTestContentProtection.Count > 0){
dynamic contentProt = new ExpandoObject();
contentProt.contentProtection = contentProtection;
attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt );
@ -351,22 +337,10 @@ 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 => {

View file

@ -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 is{ Attributes: not null }){
if (el != null && el.Attributes != 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{

View file

@ -14,18 +14,20 @@ 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){
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"@base") != null){
//TODO
Console.WriteLine("UNTESTED PARSING");
segmentsFn = SegmentBase.SegmentsFromBase;
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.@base);
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"list") != null){
//TODO
Console.Error.WriteLine("UNTESTED PARSING");
Console.WriteLine("UNTESTED PARSING");
segmentsFn = SegmentList.SegmentsFromList;
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.list);
}
@ -37,7 +39,7 @@ public class ToPlaylistsClass{
return segmentsInfo;
}
List<dynamic> segments = segmentsFn(segmentAttributes, ObjectUtilities.GetMemberValue(input.segmentInfo, "segmentTimeline"));
List<dynamic> segments = segmentsFn(segmentAttributes, input.segmentInfo.segmentTimeline);
// Duration processing
if (ObjectUtilities.GetMemberValue(segmentAttributes,"duration") != null){

View file

@ -3,34 +3,32 @@ 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 = 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){
public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic> segmentTimeline){
if (attributes.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 = baseUrl,
source = ObjectUtilities.GetMemberValue(initialization, "sourceURL"),
range = ObjectUtilities.GetMemberValue(initialization, "range")
baseUrl = attributes.baseUrl,
source = initialization.sourceURL,
range = initialization.range
});
dynamic segment = UrlType.UrlTypeToSegment(new{
baseUrl = baseUrl,
source = baseUrl,
baseUrl = attributes.baseUrl,
source = attributes.baseUrl,
indexRange = indexRange
});
@ -38,7 +36,6 @@ 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;
@ -49,7 +46,6 @@ public class SegmentBase{
}
segment.presentationTime = presentationTime ?? periodStart;
segment.number = number;
return new List<dynamic>{ segment };

View file

@ -1,29 +1,18 @@
using System;
using System.Dynamic;
using CRD.Utils.Parser.Utils;
namespace CRD.Utils.Parser.Segments;
public class UrlType{
public static dynamic UrlTypeToSegment(dynamic input){
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")
);
dynamic segment = new {
uri = ObjectUtilities.GetMemberValue(input,"source"),
resolvedUri = new Uri(new Uri(input.baseUrl, UriKind.Absolute), input.source).ToString()
};
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;

View file

@ -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;
}

View file

@ -5,12 +5,10 @@ using System.Xml;
namespace CRD.Utils.Parser.Utils;
public class XMLUtils{
public static List<XmlElement> FindChildren(XmlElement parent, string name){
return From(parent.ChildNodes).OfType<XmlElement>().Where(child => child.Name == name).ToList();
public static List<XmlElement> FindChildren(XmlElement element, string name){
return From(element.ChildNodes).OfType<XmlElement>().Where(child => child.Name == name).ToList();
}
public static string GetContent(XmlElement element){
return element.InnerText.Trim();
}

View file

@ -1,83 +0,0 @@
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();
}

View file

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

View file

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

View file

@ -1,117 +0,0 @@
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;
}
}

View file

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

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CRD.Utils.Sonarr.Models;
@ -14,15 +13,6 @@ 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>
@ -148,8 +138,4 @@ public class SonarrEpisode{
/// </value>
[JsonProperty("id")]
public int Id{ get; set; }
[JsonProperty("series")]
public SonarrSeries? Series{ get; set; }
}

View file

@ -123,21 +123,6 @@ 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}");
@ -154,11 +139,11 @@ public class SonarrClient{
}
public async Task<SonarrEpisode?> GetEpisode(int episodeId){
var json = await GetJson($"/v3/episode/{episodeId}");
SonarrEpisode? episode = null;
public async Task<SonarrEpisode> GetEpisode(int episodeId){
var json = await GetJson($"/v3/episode/id={episodeId}");
var episode = new SonarrEpisode();
try{
episode = Helpers.Deserialize<SonarrEpisode>(json,null);
episode = Helpers.Deserialize<SonarrEpisode>(json,null) ?? new SonarrEpisode();
} catch (Exception e){
MainWindow.Instance.ShowError("Sonarr GetEpisode error \n" + e);
Console.Error.WriteLine("Sonarr GetEpisode error \n" + e);

View file

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

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using CRD.Utils.Http;
using CRD.Utils.Sonarr;
using CRD.ViewModels;
using Newtonsoft.Json;
@ -9,9 +8,6 @@ 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; }
@ -21,9 +17,6 @@ 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; }
@ -63,14 +56,6 @@ 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; }
@ -96,9 +81,6 @@ public class CrDownloadOptions{
[JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; }
[JsonProperty("history_remove_missing_episodes")]
public bool HistoryRemoveMissingEpisodes{ get; set; } = true;
[JsonProperty("history_lang")]
public string? HistoryLang{ get; set; }
@ -111,13 +93,6 @@ 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; }
@ -166,20 +141,6 @@ 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
@ -275,9 +236,6 @@ 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; }
@ -323,9 +281,6 @@ 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; }
@ -356,9 +311,6 @@ 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; }

View file

@ -4,20 +4,7 @@ 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; }
@ -33,14 +20,8 @@ public class CrProfile{
[JsonProperty("preferred_content_subtitle_language")]
public string? PreferredContentSubtitleLanguage{ 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 Subscription? Subscription{ get; set; }
[JsonIgnore]
public bool HasPremium{ get; set; }

View file

@ -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; }
}

View file

@ -246,12 +246,6 @@ 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;
}
@ -291,20 +285,7 @@ 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(){
@ -385,13 +366,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; }
@ -403,7 +384,6 @@ 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; }
@ -418,53 +398,18 @@ public class CrunchyEpMeta{
public bool HighlightAllAvailable{ get; set; }
[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 CancellationTokenSource Cts { get; } = new();
}
public class DownloadProgress{
public DownloadState State{ get; set; } = DownloadState.Queued;
public DownloadState ResumeState{ get; set; } = DownloadState.Downloading;
public bool IsDownloading = false;
public bool Done = false;
public bool Error = false;
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{

View file

@ -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,10 +23,6 @@ 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{

View file

@ -1,7 +1,5 @@
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;
@ -14,23 +12,6 @@ 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; }
@ -40,8 +21,6 @@ public class CrAuthSettings{
public bool Video{ get; set; }
public bool Audio{ get; set; }
public bool UseDefault{ get; set; } = true;
}
public class StreamInfo{
@ -72,18 +51,9 @@ public class LanguageItem{
public string Language{ get; set; }
}
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{
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 List<CrunchyEpisode> Items{ get; set; }
public List<LanguageItem> Langs{ get; set; }
}
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
@ -133,7 +103,6 @@ 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; }
@ -162,11 +131,6 @@ 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; }

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CRD.Downloader;
@ -43,9 +42,6 @@ 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; }
@ -89,7 +85,7 @@ public class HistoryEpisode : INotifyPropertyChanged{
public Bitmap? ThumbnailImage{ get; set; }
[JsonIgnore]
public bool IsImageLoaded{ get; private set; }
public bool IsImageLoaded{ get; private set; } = false;
public async Task LoadImage(){
if (IsImageLoaded || string.IsNullOrEmpty(ThumbnailImageUrl))
@ -144,27 +140,21 @@ public class HistoryEpisode : INotifyPropertyChanged{
}
public async Task DownloadEpisodeDefault(){
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;
await DownloadEpisode();
}
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){
switch (EpisodeType){
case EpisodeType.MusicVideo:
await CrunchyrollManager.Instance.CrQueue.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break;
case EpisodeType.Concert:
await CrunchyrollManager.Instance.CrQueue.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break;
case EpisodeType.Episode:
case EpisodeType.Unknown:
default:
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
await QueueManager.Instance.CrAddEpisodeToQueue(EpisodeId ?? string.Empty,
string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang) ? CrunchyrollManager.Instance.DefaultLocale : CrunchyrollManager.Instance.CrunOptions.HistoryLang,
CrunchyrollManager.Instance.CrunOptions.DubLang, false, episodeDownloadMode);
break;

View file

@ -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 ? $"Specials {SeasonNum}" : $"Season {SeasonNum}";
public string CombinedProperty => SpecialSeason ?? false ? $"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;
private bool Loading = false;
[JsonIgnore]
public StringItem? SelectedVideoQualityItem{

View file

@ -81,7 +81,7 @@ public class HistorySeries : INotifyPropertyChanged{
public Bitmap? ThumbnailImage{ get; set; }
[JsonIgnore]
public bool IsImageLoaded{ get; private set; }
public bool IsImageLoaded{ get; private set; } = false;
[JsonIgnore]
public bool FetchingData{ get; set; }
@ -112,7 +112,7 @@ public class HistorySeries : INotifyPropertyChanged{
#region Settings Override
[JsonIgnore]
private bool Loading;
private bool Loading = false;
[JsonIgnore]
public StringItem? _selectedVideoQualityItem;
@ -246,19 +246,8 @@ public class HistorySeries : INotifyPropertyChanged{
}
public void UpdateNewEpisodes(){
NewEpisodes = EnumerateEpisodes().Count();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
}
int count = 0;
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;
@ -268,56 +257,64 @@ public class HistorySeries : INotifyPropertyChanged{
!string.IsNullOrEmpty(SonarrSeriesId);
bool skipUnmonitored = options.HistorySkipUnmonitored;
bool countMissing = options.HistoryCountMissing;
bool useSonarr = sonarrEnabled && options.HistoryCountSonarr;
bool useSonarrCounting = options.HistoryCountSonarr;
for (int i = Seasons.Count - 1; i >= 0; i--){
var season = Seasons[i];
var episodes = season.EpisodesList;
if (season.SpecialSeason){
if (season.SpecialSeason == true){
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, useSonarr, countMissing, false))
yield return ep;
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
count++;
}
}
}
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, useSonarr, countMissing, false)){
yield return ep;
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
count++;
}
continue;
}
if (ShouldCountEpisode(ep, useSonarr, countMissing, foundWatched)){
yield return ep;
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){
count++;
} else{
foundWatched = true;
//if not count specials break
if (!historyAddSpecials && !countMissing){
break;
}
}
}
if (!historyAddSpecials && !countMissing)
if (foundWatched && !historyAddSpecials && !countMissing){
break;
}
}
if (foundWatched && !historyAddSpecials && !countMissing)
break;
}
NewEpisodes = count;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
}
private bool ShouldCountEpisode(HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){
@ -332,6 +329,71 @@ 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;
@ -350,7 +412,6 @@ public class HistorySeries : INotifyPropertyChanged{
}
break;
case SeriesType.Movie:
case SeriesType.Series:
case SeriesType.Unknown:
default:
@ -397,7 +458,6 @@ 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:
@ -407,62 +467,55 @@ public class HistorySeries : INotifyPropertyChanged{
}
public void UpdateSeriesFolderPath(){
// Reset state first
SeriesFolderPath = string.Empty;
SeriesFolderPathExists = false;
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
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;
}
// Season path
if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){
if (season is{ SeasonDownloadPath: not null }){
try{
var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath);
var seasonPath = season.SeasonDownloadPath;
var directoryInfo = new DirectoryInfo(seasonPath);
var parentFolder = directoryInfo.Parent?.FullName;
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
SeriesFolderPath = parentFolder;
if (Directory.Exists(parentFolderPath)){
SeriesFolderPath = parentFolderPath;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
}
} catch (Exception e){
Console.Error.WriteLine($"Error resolving season folder: {e.Message}");
}
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
}
} else{
string customPath;
// Auto generated path
if (string.IsNullOrEmpty(SeriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
if (string.IsNullOrEmpty(SeriesTitle))
return;
}
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
if (string.IsNullOrEmpty(seriesTitle))
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);
}
string basePath =
!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath)
? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath
: CfgManager.PathVIDEOS_DIR;
var customPath = Path.Combine(basePath, seriesTitle);
// Check if custom path exists
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
}

View file

@ -127,7 +127,8 @@ 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 ?? []) || subSet.Contains("all"));
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) &&
subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []);
if (higlight){
Foreground = Brushes.Orange;

View file

@ -155,7 +155,7 @@ public class HighlightingTextBlock : TextBlock{
foreach (var item in Items){
var run = new Run(item);
if (highlightSet.Contains(item) || highlightSet.Contains("all")){
if (highlightSet.Contains(item)){
run.Foreground = Brushes.Orange;
// run.FontWeight = FontWeight.Bold;
}

View file

@ -10,31 +10,51 @@ 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 : ObservableObject{
public double Progress;
public bool Failed;
public string LatestVersion = "";
public List<GithubJson> GhAuthJson = [];
public class Updater : INotifyPropertyChanged{
public double progress = 0;
public bool failed = false;
public static Updater Instance{ get; } = new();
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));
}
private string downloadUrl = "";
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 tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update.zip");
private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "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)){
@ -68,49 +88,39 @@ public class Updater : ObservableObject{
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(ApiEndpoint);
var releases = Helpers.Deserialize<List<GithubRelease>>(response, null) ?? [];
var response = await client.GetStringAsync(apiEndpointLatest);
var releaseInfo = Helpers.Deserialize<GithubRelease>(response, null);
bool allowPrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
var selectedRelease = releases
.FirstOrDefault(r => allowPrereleases || !r.Prerelease);
if (selectedRelease == null){
Console.WriteLine("No valid releases found.");
if (releaseInfo == null){
Console.WriteLine($"Failed to get Update info");
return false;
}
LatestVersion = selectedRelease.TagName;
latestVersion = releaseInfo.TagName;
var latestVersion = NuGetVersion.Parse(selectedRelease.TagName.TrimStart('v'));
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;
}
}
if (latestVersion > currentVersion){
Console.WriteLine($"Update available: {LatestVersion} - Current Version: {currentVersion}");
var asset = selectedRelease.Assets?
.FirstOrDefault(a => a.IsForPlatform(platformName));
if (asset == null){
if (string.IsNullOrEmpty(downloadUrl)){
Console.WriteLine($"Failed to get Update url for {platformName}");
return false;
}
downloadUrl = asset.BrowserDownloadUrl;
var version = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = $"v{version?.Major}.{version?.Minor}.{version?.Build}";
if (latestVersion != currentVersion){
Console.WriteLine("Update available: " + latestVersion + " - Current Version: " + currentVersion);
_ = UpdateChangelogAsync();
return true;
}
@ -125,25 +135,6 @@ public class Updater : ObservableObject{
}
}
public async Task CheckGhJsonAsync(){
var url = "https://Crunchy-DL.github.io/Crunchy-Downloader/data.json";
try{
HttpClientHandler handler = new HttpClientHandler();
handler.UseProxy = false;
using (var client = new HttpClient(handler)){
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
var response = await client.GetStringAsync(url);
var authList = Helpers.Deserialize<List<GithubJson>>(response, null);
if (authList is{ Count: > 0 }){
GhAuthJson = authList;
}
}
} catch (Exception e){
Console.Error.WriteLine("Failed to get GH CR Auth information");
}
}
public async Task UpdateChangelogAsync(){
var client = HttpClientReq.Instance.GetHttpClient();
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
@ -154,25 +145,17 @@ public class Updater : ObservableObject{
existingVersion = "v1.0.0";
}
if (string.IsNullOrEmpty(LatestVersion)){
LatestVersion = "v1.0.0";
if (string.IsNullOrEmpty(latestVersion)){
latestVersion = "v1.0.0";
}
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){
if (existingVersion == latestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(latestVersion.TrimStart('v'))){
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);
@ -208,16 +191,10 @@ public class Updater : ObservableObject{
return string.Empty;
string[] lines = File.ReadAllLines(changelogFilePath);
foreach (string line in lines){
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;
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]");
if (match.Success)
return match.Groups[1].Value;
}
return string.Empty;
@ -254,7 +231,7 @@ public class Updater : ObservableObject{
public async Task DownloadAndUpdateAsync(){
try{
Failed = false;
failed = false;
Helpers.EnsureDirectoriesExist(tempPath);
// Download the zip file
@ -272,8 +249,8 @@ public class Updater : ObservableObject{
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;
}
@ -281,8 +258,8 @@ public class Updater : ObservableObject{
totalBytesRead += bytesRead;
if (totalBytes != -1){
Progress = (double)totalBytesRead / totalBytes * 100;
OnPropertyChanged(nameof(Progress));
progress = (double)totalBytesRead / totalBytes * 100;
OnPropertyChanged(nameof(progress));
}
} while (isMoreToRead);
}
@ -296,13 +273,13 @@ public class Updater : ObservableObject{
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));
}
}
@ -324,8 +301,8 @@ public class Updater : ObservableObject{
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;
}
}
@ -342,28 +319,16 @@ public class Updater : ObservableObject{
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 List<GithubAsset>? Assets{ get; set; } = [];
public dynamic? Assets{ get; set; }
public string Body{ get; set; } = string.Empty;
[JsonProperty("published_at")]
@ -371,50 +336,4 @@ public class Updater : ObservableObject{
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);
}
}
}

View file

@ -7,9 +7,6 @@ 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;
@ -26,9 +23,6 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty]
private string _loginLogoutText = "";
[ObservableProperty]
private bool _hasMultiProfile;
[ObservableProperty]
private string _remainingTime = "";
@ -56,8 +50,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RemainingTime = "Subscription maybe ended";
}
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
}
} else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
@ -65,18 +59,13 @@ public partial class AccountPageViewModel : ViewModelBase{
}
public void UpdatetProfile(){
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
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
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription;
if (subscriptions != null){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
@ -95,8 +84,8 @@ public partial class AccountPageViewModel : ViewModelBase{
UnknownEndDate = true;
}
if (!UnknownEndDate){
_targetTime = subscriptions.NextRenewalDate;
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate;
_timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1)
};
@ -112,8 +101,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RaisePropertyChanged(nameof(RemainingTime));
if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
}
}
@ -148,41 +137,6 @@ 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);

View file

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

View file

@ -32,9 +32,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _showUpcomingEpisodes;
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty]
private bool _hideDubs;
@ -77,7 +74,6 @@ 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];
@ -293,14 +289,4 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings();
}
}
partial void OnUpdateHistoryFromCalendarChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
CfgManager.WriteCrSettings();
}
}

View file

@ -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.ClearQueue();
QueueManagerIns.Queue.Clear();
foreach (var crunchyEpMeta in items){
if (!crunchyEpMeta.DownloadProgress.IsDone){
if (!crunchyEpMeta.DownloadProgress.Done){
foreach (var downloadItemDownloadedFile in crunchyEpMeta.downloadedFiles){
try{
if (File.Exists(downloadItemDownloadedFile)){
@ -85,20 +85,8 @@ public partial class DownloadsPageViewModel : ViewModelBase{
var items = QueueManagerIns.Queue;
foreach (var crunchyEpMeta in items){
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();
if (crunchyEpMeta.DownloadProgress.Error){
crunchyEpMeta.DownloadProgress = new();
}
}
@ -126,7 +114,6 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
public bool Error{ get; set; }
public bool ShowPauseIcon{ get; set; }
public DownloadItemModel(CrunchyEpMeta epMetaF){
epMeta = epMetaF;
@ -135,9 +122,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;
Done = epMeta.DownloadProgress.IsDone;
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
ShowPauseIcon = isDownloading;
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
Done = epMeta.DownloadProgress.Done;
Percent = epMeta.DownloadProgress.Percent;
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss");
DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits
@ -145,8 +132,8 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
;
Paused = epMeta.DownloadProgress.IsPaused;
DoingWhat = Paused ? "Paused" :
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
DoingWhat = epMeta.Paused ? "Paused" :
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
@ -157,7 +144,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
);
InfoTextHover = epMeta.AvailableQualities;
Error = epMeta.DownloadProgress.IsError;
Error = epMeta.DownloadProgress.Error;
}
string JoinWithSeparator(params string[] parts){
@ -205,17 +192,16 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
}
public void Refresh(){
Done = epMeta.DownloadProgress.IsDone;
isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing;
ShowPauseIcon = isDownloading;
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
Done = epMeta.DownloadProgress.Done;
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.DownloadProgress.IsPaused;
DoingWhat = Paused ? "Paused" :
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
DoingWhat = epMeta.Paused ? "Paused" :
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
@ -225,12 +211,11 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
epMeta.Resolution
);
InfoTextHover = epMeta.AvailableQualities;
Error = epMeta.DownloadProgress.IsError;
Error = epMeta.DownloadProgress.Error;
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)));
@ -246,97 +231,52 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
[RelayCommand]
public void ToggleIsDownloading(){
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;
if (isDownloading){
//StopDownload();
epMeta.Paused = !epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(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)));
} else{
if (epMeta.Paused){
epMeta.Paused = false;
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
return;
}
} else{
StartDownload();
}
[RelayCommand]
public void RetryDownload(){
epMeta.DownloadProgress.ResetForRetry();
isDownloading = false;
Paused = false;
ShowPauseIcon = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
QueueManager.Instance.RefreshQueue();
StartDownload();
}
public Task StartDownload(){
QueueManager.Instance.TryStartDownload(this);
return Task.CompletedTask;
if (PropertyChanged != null){
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading"));
}
}
internal async Task StartDownloadCore(){
if (isDownloading)
return;
epMeta.RenewCancellationToken();
public async void StartDownload(){
if (!isDownloading){
isDownloading = true;
epMeta.DownloadProgress.State = DownloadState.Downloading;
Paused = false;
ShowPauseIcon = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
epMeta.DownloadProgress.IsDownloading = true;
Paused = !epMeta.Paused && !isDownloading || epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
CrDownloadOptions newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (epMeta.OnlySubs){
newOptions?.Novids = true;
newOptions?.Noaudio = true;
newOptions.Novids = true;
newOptions.Noaudio = true;
}
await CrunchyrollManager.Instance.DownloadEpisode(
epMeta,
epMeta.DownloadSettings ?? newOptions ?? CrunchyrollManager.Instance.CrunOptions);
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions);
}
}
[RelayCommand]
public void RemoveFromQueue(){
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
if (downloadItem != null){
QueueManager.Instance.RemoveFromQueue(downloadItem);
epMeta.CancelDownload();
QueueManager.Instance.Queue.Remove(downloadItem);
epMeta.Cts.Cancel();
if (!Done){
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
try{

View file

@ -87,9 +87,6 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _showArtists;
[ObservableProperty]
private bool _showMovies = true;
[ObservableProperty]
private static bool _viewSelectionOpen;
@ -119,15 +116,6 @@ 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]
@ -163,7 +151,6 @@ 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 };
@ -278,14 +265,6 @@ 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){
@ -345,36 +324,11 @@ 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);
@ -420,10 +374,6 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand]
public void NavToSeries(){
@ -469,7 +419,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.DownloadEpisodeDefault())
.Select(historyEpisode => historyEpisode.DownloadEpisode())
);
}
@ -565,7 +515,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -578,7 +528,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.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
}
@ -586,7 +536,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.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -595,7 +545,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
@ -605,7 +555,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
}
@ -617,10 +567,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
historyEpisode.ToggleWasDownloaded();
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId);
}
}
seriesArgs.Season.UpdateDownloaded();
}
seriesArgs.Series?.UpdateNewEpisodes();
@ -678,7 +627,6 @@ public class HistoryPageProperties{
public bool ShowSeries{ get; set; } = true;
public bool ShowArtists{ get; set; } = true;
public bool ShowMovies{ get; set; } = true;
}
public class SeasonsPageProperties{

View file

@ -109,20 +109,6 @@ 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){
@ -227,7 +213,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -240,7 +226,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.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
}
@ -250,7 +236,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
@ -260,7 +246,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
}
@ -268,7 +254,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.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -276,11 +262,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.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){
historyEpisode.ToggleWasDownloaded();
foreach (var historyEpisode in season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
season.UpdateDownloaded(historyEpisode.EpisodeId);
}
}
season.UpdateDownloaded();
}
[RelayCommand]

View file

@ -15,7 +15,6 @@ 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;
@ -253,6 +252,7 @@ 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,27 +462,45 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
private ObservableCollection<SeasonViewModel> GetTargetSeasonsAndYears(){
var seasons = new[]{ "WINTER", "SPRING", "SUMMER", "FALL" };
var now = DateTime.Now;
DateTime now = DateTime.Now;
int currentMonth = now.Month;
int currentYear = now.Year;
int currentSeasonIndex = (now.Month - 1) / 3;
var result = new ObservableCollection<SeasonViewModel>();
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 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 rawIndex = currentSeasonIndex + i;
int targetIndex = (currentSeasonIndex + i + 4) % 4;
string targetSeason = seasons[targetIndex];
int targetYear = currentYear;
int yearOffset = (int)Math.Floor(rawIndex / 4.0);
int seasonIndex = (rawIndex % 4 + 4) % 4;
result.Add(new SeasonViewModel{
Season = seasons[seasonIndex],
Year = currentYear + yearOffset
});
if (i < 0 && targetIndex == 3){
targetYear--;
} else if (i > 0 && targetIndex == 0){
targetYear++;
}
return result;
targetSeasons.Add(new SeasonViewModel(){ Season = targetSeason, Year = targetYear });
}
return targetSeasons;
}
public void SelectionChangedOfSeries(AnilistSeries? value){
@ -566,6 +584,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
private void FilterItems(){
List<AnilistSeries> filteredList;
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){

View file

@ -8,7 +8,6 @@ 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;
@ -17,7 +16,6 @@ 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;
@ -28,50 +26,35 @@ namespace CRD.ViewModels;
public partial class UpdateViewModel : ViewModelBase{
[ObservableProperty]
private bool updating;
private bool _updateAvailable;
[ObservableProperty]
private double progress;
private bool _updating;
[ObservableProperty]
private bool failed;
private double _progress;
[ObservableProperty]
private string currentVersion;
private bool _failed;
private AccountPageViewModel accountPageViewModel;
[ObservableProperty]
private bool ghUpdatePrereleases;
private string _currentVersion;
public ObservableCollection<Control> ChangelogBlocks{ get; } = new();
public ProgramManager ProgramManager { get; }
public UpdateViewModel(){
var version = Assembly
.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0];
CurrentVersion = $"v{version}";
ProgramManager = ProgramManager.Instance;
var version = Assembly.GetExecutingAssembly().GetName().Version;
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
LoadChangelog();
GhUpdatePrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
UpdateAvailable = ProgramManager.Instance.UpdateAvailable;
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;
@ -80,10 +63,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;
}
}

View file

@ -19,37 +19,34 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
private readonly ContentDialog dialog;
[ObservableProperty]
private bool editMode;
private bool _editMode;
[ObservableProperty]
private string presetName;
private string _presetName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCodec))]
private string codec;
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;
public bool HasCodec => !string.IsNullOrWhiteSpace(Codec);
private bool _fileExists;
public ObservableCollection<VideoPreset> CustomPresetsList{ get; } = new(){ };
@ -111,7 +108,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{
PresetName = value.PresetName ?? "";
Codec = value.Codec ?? "";
Crf = value.Crf;
FrameRate = value.FrameRate ?? "24000/1001";
FrameRate = value.FrameRate ?? "24";
SelectedResolution = ResolutionList.FirstOrDefault(e => e.Content?.ToString() == value.Resolution) ?? ResolutionList.First();
AdditionalParameters.Clear();

View file

@ -81,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
[RelayCommand]
public void DownloadEpisode(HistoryEpisode episode){
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath,false);
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){

View file

@ -1,55 +0,0 @@
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;
}
}

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
@ -19,144 +18,104 @@ 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 historyRemoveMissingEpisodes;
private bool _historyAddSpecials;
[ObservableProperty]
private bool historyAddSpecials;
private bool _historySkipUnmonitored;
[ObservableProperty]
private bool historySkipUnmonitored;
private bool _historyCountSonarr;
[ObservableProperty]
private bool historyCountSonarr;
private double? _simultaneousDownloads;
[ObservableProperty]
private double? historyAutoRefreshIntervalMinutes;
private double? _simultaneousProcessingJobs;
[ObservableProperty]
private HistoryRefreshMode historyAutoRefreshMode;
private bool _downloadMethodeNew;
[ObservableProperty]
private string historyAutoRefreshModeHint;
private bool _downloadAllowEarlyStart;
[ObservableProperty]
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 },
};
private double? _downloadSpeed;
[ObservableProperty]
private double? simultaneousDownloads;
private bool _downloadSpeedInBits;
[ObservableProperty]
private double? simultaneousProcessingJobs;
private double? _retryAttempts;
[ObservableProperty]
private bool downloadMethodeNew;
private double? _retryDelay;
[ObservableProperty]
private bool downloadOnlyWithAllSelectedDubSub;
private ComboBoxItem _selectedHistoryLang;
[ObservableProperty]
private bool downloadAllowEarlyStart;
private ComboBoxItem? _currentAppTheme;
[ObservableProperty]
private bool persistQueue;
private bool _useCustomAccent;
[ObservableProperty]
private double? downloadSpeed;
private string _backgroundImagePath;
[ObservableProperty]
private bool downloadSpeedInBits;
private double? _backgroundImageOpacity;
[ObservableProperty]
private double? retryAttempts;
private double? _backgroundImageBlurRadius;
[ObservableProperty]
private double? retryDelay;
private Color _listBoxColor;
[ObservableProperty]
private bool trayIconEnabled;
private Color _customAccentColor = Colors.SlateBlue;
[ObservableProperty]
private bool startMinimizedToTray;
private string _sonarrHost = "localhost";
[ObservableProperty]
private bool minimizeToTray;
private string _sonarrPort = "8989";
[ObservableProperty]
private bool minimizeToTrayOnClose;
private string _sonarrApiKey = "";
[ObservableProperty]
private ComboBoxItem selectedHistoryLang;
private bool _sonarrUseSsl = false;
[ObservableProperty]
private ComboBoxItem? currentAppTheme;
private bool _sonarrUseSonarrNumbering = false;
[ObservableProperty]
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;
private bool _logMode = false;
public ObservableCollection<Color> PredefinedColors{ get; } = new(){
Color.FromRgb(255, 185, 0),
@ -211,105 +170,84 @@ 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;
private bool _flareSolverrUseSsl = false;
[ObservableProperty]
private bool useFlareSolverr;
private bool _useFlareSolverr = false;
[ObservableProperty]
private string mitmFlareSolverrHost = "localhost";
private string _tempDownloadDirPath;
[ObservableProperty]
private string mitmFlareSolverrPort = "8080";
private bool _downloadFinishedPlaySound;
[ObservableProperty]
private bool mitmFlareSolverrUseSsl;
private string _downloadFinishedSoundPath;
[ObservableProperty]
private bool useMitmFlareSolverr;
private string _currentIp = "";
[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 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()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0];
CurrentVersion = $"v{version}";
var version = Assembly.GetExecutingAssembly().GetName().Version;
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
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);
@ -326,9 +264,6 @@ 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;
@ -354,15 +289,6 @@ 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 ?? "";
@ -371,19 +297,13 @@ 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;
@ -391,11 +311,6 @@ 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];
@ -405,16 +320,6 @@ 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;
}
@ -427,12 +332,8 @@ 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);
@ -444,17 +345,14 @@ 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.SetProcessingLimit(settings.SimultaneousProcessingJobs);
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs);
settings.ProxyEnabled = ProxyEnabled;
settings.ProxySocks = ProxySocks;
@ -469,8 +367,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;
}
@ -505,34 +403,11 @@ 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]
@ -554,7 +429,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath,
defaultPath: CfgManager.PathVIDEOS_DIR
);
}
@ -566,18 +441,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath,
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"
});
@ -615,7 +490,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImagePath = path;
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath,
defaultPath: string.Empty
);
}
@ -644,35 +519,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path;
},
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,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath,
defaultPath: string.Empty
);
}
@ -685,12 +532,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
@ -709,13 +556,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();
@ -723,7 +570,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;
@ -754,14 +601,9 @@ 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);
@ -771,24 +613,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(ListBoxColor)
or nameof(CurrentAppTheme)
or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)
or nameof(PersistQueue)){
or nameof(LogMode)){
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)){
@ -828,7 +658,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
[RelayCommand]
public async Task CheckIp(){
public async void 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){
@ -844,9 +674,4 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CfgManager.DisableLogMode();
}
}
partial void OnPersistQueueChanged(bool value){
UpdateSettings();
QueueManager.Instance.SaveQueueSnapshot();
}
}

View file

@ -3,7 +3,6 @@
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">
@ -14,34 +13,14 @@
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
<Grid Width="170" Height="170" Margin="20"
HorizontalAlignment="Center"
VerticalAlignment="Top">
<!-- Profile Image -->
<Image Source="{Binding ProfileImage}">
<Image Width="170" Height="170" Margin="20"
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" />

View file

@ -99,10 +99,6 @@
<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>
@ -189,8 +185,7 @@
<Grid HorizontalAlignment="Center">
<Grid>
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" />
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" IsVisible="{Binding AnilistEpisode}" MaxHeight="150" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" MaxHeight="150" Source="{Binding ImageBitmap}" />
</Grid>

View file

@ -43,20 +43,6 @@
</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 "
@ -122,11 +108,11 @@
HorizontalAlignment="Right" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="{Binding
ShowPauseIcon, Converter={StaticResource UiValueConverter}}" FontSize="18" />
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" />
</StackPanel>
</Button>
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding RetryDownload}" FontStyle="Italic"
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic"
HorizontalAlignment="Right" VerticalAlignment="Top">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />

View file

@ -70,48 +70,6 @@
</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">
@ -157,7 +115,6 @@
<!-- <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"
@ -257,16 +214,6 @@
<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" />

View file

@ -69,9 +69,7 @@
IconSource="Library" />
</ui:NavigationView.MenuItems>
<ui:NavigationView.FooterMenuItems>
<ui:NavigationViewItem Classes="SampleAppNav"
Classes.redDot="{Binding ProgramManager.UpdateAvailable}"
Content="Update" Tag="Update"
<ui:NavigationViewItem Classes="SampleAppNav" Content="Update" Tag="Update" Opacity="{Binding ProgramManager.OpacityButton}"
IconSource="CloudDownload" Focusable="False" />
<ui:NavigationViewItem Classes="SampleAppNav" Content="Account" Tag="Account"
IconSource="Contact" />

View file

@ -5,7 +5,6 @@ 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;
@ -48,8 +47,6 @@ public partial class MainWindow : AppWindow{
private object selectedNavVieItem;
private ToastNotification? toast;
private const int TitleBarHeightAdjustment = 31;
private PixelPoint _restorePosition;
@ -73,7 +70,6 @@ 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");
@ -154,48 +150,55 @@ public partial class MainWindow : AppWindow{
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds));
var toastControl = this.FindControl<ToastNotification>("Toast");
toastControl?.Show(message, type, durationInSeconds);
}
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){
if (sender is NavigationView navView){
var selectedItem = navView.SelectedItem as NavigationViewItem;
if (selectedItem != null){
switch (selectedItem.Tag){
case "DownloadQueue":
navView.Content = Activator.CreateInstance<DownloadsPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "AddDownload":
navView.Content = Activator.CreateInstance<AddDownloadPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Calendar":
navView.Content = Activator.CreateInstance<CalendarPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "History":
navView.Content = Activator.CreateInstance<HistoryPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel));
navigationStack.Clear();
navigationStack.Push(navView.Content);
selectedNavVieItem = selectedItem;
break;
case "Seasons":
navView.Content = Activator.CreateInstance<UpcomingPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Account":
navView.Content = Activator.CreateInstance<AccountPageViewModel>();
navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Settings":
var viewModel = Activator.CreateInstance<SettingsPageViewModel>();
var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel));
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "Update":
navView.Content = Activator.CreateInstance<UpdateViewModel>();
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel));
selectedNavVieItem = selectedItem;
break;
default:
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
break;
}
}
}
}
@ -206,7 +209,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);

View file

@ -161,13 +161,6 @@
<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},
@ -653,13 +646,6 @@
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},

View file

@ -1,6 +1,5 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using CRD.Downloader;
using CRD.Utils.Sonarr;
using CRD.ViewModels;
@ -14,7 +13,6 @@ public partial class SettingsPageView : UserControl{
private void OnUnloaded(object? sender, RoutedEventArgs e){
if (DataContext is SettingsPageViewModel viewModel){
SonarrClient.Instance.RefreshSonarr();
ProgramManager.Instance.StartRunners();
}
}

View file

@ -47,21 +47,10 @@
<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 ProgramManager.UpdateAvailable}">
IsEnabled="{Binding UpdateAvailable}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<controls:SymbolIcon Symbol="Download" FontSize="32" />
<TextBlock Text="Update" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock>

View file

@ -38,15 +38,10 @@
<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 IsVisible="{Binding HasCodec}">
<StackPanel>
<TextBlock Text="Select Resolution" Margin="0,10,0,5" />
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding ResolutionList}"
@ -55,17 +50,17 @@
</StackPanel>
<!-- Frame Rate NumberBox -->
<StackPanel IsVisible="{Binding HasCodec}">
<StackPanel>
<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="24000/1001" Text="{Binding FrameRate}" />
<TextBox Watermark="24" Text="{Binding FrameRate}" />
</StackPanel>
<!-- CRF NumberBox -->
<StackPanel IsVisible="{Binding HasCodec}">
<StackPanel>
<TextBlock Text="Enter CRF (0-51) - (cq,global_quality,qp)" Margin="0,10,0,5" />
<controls:NumberBox Minimum="0" Maximum="51"
Value="{Binding Crf}"

View file

@ -1,78 +0,0 @@
<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>

View file

@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace CRD.Views.Utils;
public partial class ContentDialogMultiProfileSelectView : UserControl{
public ContentDialogMultiProfileSelectView(){
InitializeComponent();
}
}

View file

@ -45,12 +45,6 @@
</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>
@ -69,43 +63,6 @@
</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"
@ -113,12 +70,6 @@
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>
@ -131,12 +82,6 @@
</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>
@ -297,49 +242,50 @@
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
</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>
<!-- <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>
@ -477,65 +423,6 @@
</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"
@ -788,7 +675,7 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm that traffic is routed through a VPN.&#10;After enabling the VPN or changing location, restart the app; otherwise, Crunchyroll may still see the old login location.">
<controls:SettingsExpanderItem Content="IP" Description="Check your current IP address to confirm if traffic is routed through a VPN">
<controls:SettingsExpanderItem.Footer>
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>

View file

@ -1,33 +0,0 @@
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

View file

@ -1,16 +0,0 @@
#!/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"

View file

@ -1,9 +0,0 @@
[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)

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more