Compare commits

...

11 commits

Author SHA1 Message Date
Elwador
638c412e49 Chg: Docker tags 2026-04-18 13:07:37 +02:00
Elwador
9be2c65eb2 Chg: Add missing image 2026-04-18 12:18:24 +02:00
Elwador
2715069ceb Fix: Workflow 2026-04-18 12:09:26 +02:00
Elwador
4952d74aa6 Add: webtop Docker support 2026-04-17 22:29:56 +02:00
Elwador
aabc10e1d8 - Added **red dot indicator** when a new update is available
- Added **FlareSolverr MITM proxy** support
- Added **Sonarr variables** to filename formatting
- Changed **queue handling**
- Updated **Android TV token**
- Changed some **authentication error messages**
2026-03-30 19:26:16 +02:00
Elwador
c4ba220d1b - Added **UseDefaults toggle** for endpoints to choose between **app defaults** (auto-updated with new releases) or **custom parameters**
- Added **authentication parameters** to the **Android TV endpoint**
- Changed **parser and HLS download handling** to support the new manifest/codec format
- Refactored **MKVMerge and FFmpeg command building**
- Updated android tv token
2026-03-24 12:15:22 +01:00
Elwador
e58a6bb32c Chg - Updated images 2026-03-15 10:22:09 +01:00
Elwador
4c330560aa - Added option to **only add episodes to the queue if all selected dubs/subs are available**
- Added option to **enable/disable font extraction from typesetting**
- Changed **Execute on Queue Finished** to allow executing `.ps1` files
- Changed **tray tooltip** to show queue size and next auto refresh
- Changed **tray menu** to include an option to **refresh history**
- Changed **history add-to-queue order** to add episodes from **1 → x** instead of **x → 1**
- Changed **updater logic** to only select releases with higher version numbers
- Changed **updater** to allow **pre-releases via a toggle**
- Fixed **font extraction from subtitles** sometimes incorrectly picking numbers
- Fixed **displayed seasons** in the upcoming seasons tab
2026-03-15 10:21:47 +01:00
Elwador
199ff9f96c Chg - Updated images 2026-03-04 18:17:53 +01:00
Elwador
985fd9c00f Added **tray icon** [#393](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/393).
Added **ability to switch between account profiles** [#372](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/372).
Added option to **execute a file when the download queue finishes** [#392](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/392).
Added **auto history refresh / auto add to queue** [#394](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/394).
Changed **font loading** to also include fonts from the local fonts folder that are not available on Crunchyroll [#371](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/371).
Updated packages to latest versions
Fixed **history not being saved** after it was updated via the calendar
Fixed **Downloaded toggle in history** being slow for large seasons
2026-03-04 18:17:28 +01:00
Elwador
973c45ce5c - Added option to **update history from the calendar**
- Added **search for currently shown series** in the history view
- Changed **authentication tokens**
- Fixed **download info updates** where the resolution was shown incorrectly
2026-01-31 19:11:58 +01:00
113 changed files with 5491 additions and 2368 deletions

14
.dockerignore Normal file
View file

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

View file

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

View file

@ -1,15 +1,25 @@
using System; using System;
using System.Globalization;
using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using CRD.ViewModels; using CRD.ViewModels;
using MainWindow = CRD.Views.MainWindow; using MainWindow = CRD.Views.MainWindow;
using System.Linq; using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using CRD.Downloader; using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
namespace CRD; namespace CRD;
public partial class App : Application{ public class App : Application{
private TrayIcon? trayIcon;
private bool exitRequested;
public override void Initialize(){ public override void Initialize(){
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
@ -21,11 +31,22 @@ public partial class App : Application{
var manager = ProgramManager.Instance; var manager = ProgramManager.Instance;
if (!isHeadless){ if (!isHeadless){
desktop.MainWindow = new MainWindow{ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var mainWindow = new MainWindow{
DataContext = new MainWindowViewModel(manager), DataContext = new MainWindowViewModel(manager),
}; };
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); }; mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
QueueManager.Instance.QueueStateChanged += (_, _) => { Dispatcher.UIThread.Post(UpdateTrayTooltip); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
desktop.MainWindow = mainWindow;
}
SetupTrayIcon(desktop, mainWindow, manager);
SetupMinimizeToTray(desktop,mainWindow,manager);
} }
@ -35,5 +56,129 @@ public partial class App : Application{
base.OnFrameworkInitializationCompleted(); 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 is{ Done: false, Error: false });
var options = CrunchyrollManager.Instance.CrunOptions;
var lastRefresh = ProgramManager.Instance.GetLastRefreshTime();
string nextRefreshString = "";
if (options.HistoryAutoRefreshIntervalMinutes != 0){
var baseTime = lastRefresh == DateTime.MinValue
? DateTime.Now
: lastRefresh;
var nextRefresh = baseTime
.AddMinutes(options.HistoryAutoRefreshIntervalMinutes)
.ToString("t", CultureInfo.CurrentCulture);
nextRefreshString = $"\nNext Refresh: {nextRefresh}";
}
trayIcon?.ToolTipText =
$"Queue: {downloadsToProcess}" + nextRefreshString;
}
public void SetTrayIconVisible(bool enabled){
trayIcon?.IsVisible = enabled;
if (!enabled && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
if (desktop.MainWindow is{ } w)
RestoreFromTray(w);
}
}
} }

BIN
CRD/Assets/app_icon.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -10,6 +10,8 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils; using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views; using CRD.Views;
@ -81,7 +83,7 @@ public class CalendarManager{
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br"); request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
(bool IsOk, string ResponseContent, string error) response; (bool IsOk, string ResponseContent, string error) response;
if (!HttpClientReq.Instance.useFlareSolverr){ if (!HttpClientReq.Instance.UseFlareSolverr){
response = await HttpClientReq.Instance.SendHttpRequest(request); response = await HttpClientReq.Instance.SendHttpRequest(request);
} else{ } else{
response = await HttpClientReq.Instance.SendFlareSolverrHttpRequest(request); response = await HttpClientReq.Instance.SendFlareSolverrHttpRequest(request);
@ -234,7 +236,16 @@ public class CalendarManager{
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true); var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){ if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data; var newEpisodes = newEpisodesBase.Data ?? [];
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar");
}
}
//EpisodeAirDate //EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){ foreach (var crBrowseEpisode in newEpisodes){

View file

@ -1,11 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; 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.Threading.Tasks;
using System.Web;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Views; using CRD.Views;
@ -15,9 +22,12 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll; namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
public CrToken? Token; public CrToken? Token;
public CrProfile Profile = new(); public CrProfile Profile = new();
public Subscription? Subscription{ get; set; }
public CrMultiProfile MultiProfile = new();
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
public CrAuthSettings AuthSettings = authSettings; public CrAuthSettings AuthSettings = authSettings;
@ -32,7 +42,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale, PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
HasPremium = false, HasPremium = false,
}; };
} }
private string GetTokenFilePath(){ private string GetTokenFilePath(){
@ -49,9 +58,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
case "console/ps5": case "console/ps5":
case "console/xbox_one": case "console/xbox_one":
return CfgManager.PathCrToken.Replace(".json", "_console.json"); return CfgManager.PathCrToken.Replace(".json", "_console.json");
case "---":
return CfgManager.PathCrToken.Replace(".json", "_guest.json");
default: default:
return CfgManager.PathCrToken; return CfgManager.PathCrToken;
} }
} }
@ -65,12 +75,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
} }
public void SetETPCookie(string refreshToken){ public void SetETPCookie(string refreshToken){
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken),cookieStore); HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"),cookieStore); HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore);
} }
public async Task AuthAnonymous(){ public async Task AuthAnonymous(){
string uuid = Guid.NewGuid().ToString(); string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
Subscription = new Subscription();
var formData = new Dictionary<string, string>{ var formData = new Dictionary<string, string>{
{ "grant_type", "client_id" }, { "grant_type", "client_id" },
@ -121,11 +133,15 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
Token.device_id = deviceId; Token.device_id = deviceId;
Token.expires = DateTime.Now.AddSeconds((double)Token.expires_in); Token.expires = DateTime.Now.AddSeconds((double)Token.expires_in);
if (EndpointEnum == CrunchyrollEndpoints.Guest){
return;
}
CfgManager.WriteJsonToFile(GetTokenFilePath(), Token); CfgManager.WriteJsonToFile(GetTokenFilePath(), Token);
} }
} }
public async Task Auth(AuthData data){ private async Task AuthOld(AuthData data){
string uuid = Guid.NewGuid().ToString(); string uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{ var formData = new Dictionary<string, string>{
@ -162,13 +178,13 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
JsonTokenToFileAndVariable(response.ResponseContent, uuid); JsonTokenToFileAndVariable(response.ResponseContent, uuid);
} else{ } else{
if (response.ResponseContent.Contains("invalid_credentials")){ if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 5)); MessageBus.Current.SendMessage(new ToastMessage("Login failed. Please check your username and password.", ToastType.Error, 5));
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") || } else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") || response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") || response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") || response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){ response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5)); MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
} else{ } else{
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {response.ResponseContent.Substring(0, response.ResponseContent.Length < 200 ? response.ResponseContent.Length : 200)}", MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {response.ResponseContent.Substring(0, response.ResponseContent.Length < 200 ? response.ResponseContent.Length : 200)}",
ToastType.Error, 5)); ToastType.Error, 5));
@ -179,7 +195,64 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){ if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token); SetETPCookie(Token.refresh_token);
await GetProfile(); await GetMultiProfile();
}
}
public async Task ChangeProfile(string profileId){
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();
}
if (Profile.Username == "???"){
return;
}
if (string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
return;
}
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
SetETPCookie(Token.refresh_token);
var formData = new Dictionary<string, string>{
{ "grant_type", "refresh_token_profile_id" },
{ "profile_id", profileId },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
if (Token?.refresh_token != null) SetETPCookie(Token.refresh_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
}
await GetMultiProfile();
} else{
Console.Error.WriteLine("Refresh Token Auth Failed");
} }
} }
@ -199,42 +272,69 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (profileTemp != null){ if (profileTemp != null){
Profile = profileTemp; Profile = profileTemp;
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null); await GetSubscription();
}
}
}
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs); private async Task GetSubscription(){
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
if (responseSubs.IsOk){ var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Profile.Subscription = subsc; if (responseSubs.IsOk){
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){ var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First(); Subscription = subsc;
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate; if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var remaining = expiration - DateTime.Now; var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
Profile.HasPremium = true; var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
if (Profile.Subscription != null){ var remaining = expiration - DateTime.Now;
Profile.Subscription.IsActive = remaining > TimeSpan.Zero; Profile.HasPremium = true;
Profile.Subscription.NextRenewalDate = expiration; if (Subscription != null){
} Subscription.IsActive = remaining > TimeSpan.Zero;
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){ Subscription.NextRenewalDate = expiration;
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
} }
} 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;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
}
}
private async Task GetMultiProfile(){
if (Token?.access_token == null){
Console.Error.WriteLine("Missing Access Token");
return;
}
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token?.access_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
MultiProfile = Helpers.Deserialize<CrMultiProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? new CrMultiProfile();
var selectedProfile = MultiProfile.Profiles.FirstOrDefault( e => e.IsSelected);
if (selectedProfile != null) Profile = selectedProfile;
await GetSubscription();
}
}
} }
} }
} }
@ -284,8 +384,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") || response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") || response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){ response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5)); MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
Console.Error.WriteLine($"Failed to login - Cloudflare error try to change to BetaAPI in settings"); Console.Error.WriteLine($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}");
} }
if (response.IsOk){ if (response.IsOk){
@ -294,18 +394,26 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){ if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token); SetETPCookie(Token.refresh_token);
await GetProfile(); await GetMultiProfile();
} }
} else{ } else{
Console.Error.WriteLine("Token Auth Failed"); Console.Error.WriteLine("Token Auth Failed");
await AuthAnonymous(); await AuthAnonymous();
MainWindow.Instance.ShowError("Login failed. Please check the log for more details."); MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
} }
} }
public async Task RefreshToken(bool needsToken){ 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 || if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){ Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous(); await AuthAnonymous();

View file

@ -6,8 +6,10 @@ using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll; namespace CRD.Downloader.Crunchyroll;
@ -73,92 +75,102 @@ public class CrEpisode(){
public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){ public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){
bool serieshasversions = true; bool serieshasversions = true;
var episode = new CrunchyRollEpisodeData();
// Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
CrunchyRollEpisodeData episode = new CrunchyRollEpisodeData();
if (crunInstance.CrunOptions.History && updateHistory){ if (crunInstance.CrunOptions.History && updateHistory){
await crunInstance.History.UpdateWithEpisodeList([dlEpisode]); 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){ if (historySeries != null){
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(false); crunInstance.History.MatchHistorySeriesWithSonarr(false);
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, historySeries); await crunInstance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile(); CfgManager.UpdateHistoryFile();
} }
} }
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier) ? dlEpisode.Identifier.Split('|')[1] : $"S{dlEpisode.SeasonNumber}"; // initial key
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}"; var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier)
episode.EpisodeAndLanguages = new EpisodeAndLanguage{ ? dlEpisode.Identifier.Split('|')[1]
Items = new List<CrunchyEpisode>(), : $"S{dlEpisode.SeasonNumber}";
Langs = new List<LanguageItem>()
};
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
episode.EpisodeAndLanguages = new EpisodeAndLanguage();
// Build Variants
if (dlEpisode.Versions != null){ if (dlEpisode.Versions != null){
foreach (var version in dlEpisode.Versions){ foreach (var version in dlEpisode.Versions){
// Ensure there is only one of the same language var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != version.AudioLocale)){ ?? Languages.DEFAULT_lang;
// Push to arrays if there are no duplicates of the same language
episode.EpisodeAndLanguages.Items.Add(dlEpisode); episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? Languages.DEFAULT_lang);
}
} }
} else{ } else{
// Episode didn't have versions, mark it as such to be logged.
serieshasversions = false; serieshasversions = false;
// Ensure there is only one of the same language
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != dlEpisode.AudioLocale)){ var lang = Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale)
// Push to arrays if there are no duplicates of the same language ?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale) ?? Languages.DEFAULT_lang); episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
}
} }
if (episode.EpisodeAndLanguages.Variants.Count == 0)
return episode;
int specialIndex = 1; var baseEp = episode.EpisodeAndLanguages.Variants[0].Item;
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; string newKey;
if (isSpecial && !string.IsNullOrEmpty(episode.EpisodeAndLanguages.Items[0].Episode)){ if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = episode.EpisodeAndLanguages.Items[0].Episode ?? "SP" + (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id); newKey = baseEp.Episode;
} else{ } else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id) : episode.EpisodeAndLanguages.Items[0].Episode ?? epIndex + "")}"; var epPart = baseEp.Episode ?? (baseEp.EpisodeNumber?.ToString() ?? "1");
newKey = isSpecial
? $"SP{epPart} {baseEp.Id}"
: $"E{epPart}";
} }
episode.Key = newKey; episode.Key = newKey;
var seasonTitle = episode.EpisodeAndLanguages.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle var seasonTitle =
?? Regex.Replace(episode.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); episode.EpisodeAndLanguages.Variants
.Select(v => v.Item.SeasonTitle)
.FirstOrDefault(t => !DownloadQueueItemFactory.HasDubSuffix(t))
?? DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle);
var title = episode.EpisodeAndLanguages.Items[0].Title; var title = baseEp.Title;
var seasonNumber = Helpers.ExtractNumberAfterS(episode.EpisodeAndLanguages.Items[0].Identifier) ?? episode.EpisodeAndLanguages.Items[0].SeasonNumber.ToString(); var seasonNumber = baseEp.GetSeasonNum();
var languages = episode.EpisodeAndLanguages.Items.Select((a, index) => var languages = episode.EpisodeAndLanguages.Variants
$"{(a.IsPremiumOnly ? "+ " : "")}{episode.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆ .Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
Console.WriteLine($"[{episode.Key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]"); 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."); Console.WriteLine("Couldn\'t find versions on episode, added languages with language array.");
}
return episode; return episode;
} }
public CrunchyEpMeta EpisodeMeta(CrunchyRollEpisodeData episodeP, List<string> dubLang){ public CrunchyEpMeta EpisodeMeta(CrunchyRollEpisodeData episodeP, List<string> dubLang){
// var ret = new Dictionary<string, CrunchyEpMeta>(); CrunchyEpMeta? retMeta = null;
var retMeta = new CrunchyEpMeta(); var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var hslang = crunInstance.CrunOptions.Hslang;
var selectedDubs = dubLang
.Where(d => episodeP.EpisodeAndLanguages.Variants.Any(v => v.Lang.CrLocale == d))
.ToList();
for (int index = 0; index < episodeP.EpisodeAndLanguages.Items.Count; index++){ foreach (var v in episodeP.EpisodeAndLanguages.Variants){
var item = episodeP.EpisodeAndLanguages.Items[index]; var item = v.Item;
var lang = v.Lang;
if (!dubLang.Contains(episodeP.EpisodeAndLanguages.Langs[index].CrLocale)) if (!dubLang.Contains(lang.CrLocale))
continue; continue;
item.HideSeasonTitle = true; item.HideSeasonTitle = true;
@ -173,67 +185,55 @@ public class CrEpisode(){
item.SeriesTitle = "NO_TITLE"; item.SeriesTitle = "NO_TITLE";
} }
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
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;
if (episodeP.EpisodeAndLanguages.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episodeP.EpisodeAndLanguages.Langs.Any(epLang => epLang.CrLocale == language))
.ToList();
}
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
item.Playback = item.StreamsLink;
}
}
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; item.SeqId = epNum;
if (retMeta == null){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeriesTitle));
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeasonTitle));
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
retMeta = DownloadQueueItemFactory.CreateShell(
service: StreamingService.Crunchyroll,
seriesTitle: seriesTitle,
seasonTitle: seasonTitle,
episodeNumber: item.Episode,
episodeTitle: item.GetEpisodeTitle(),
description: item.Description,
episodeId: item.Id,
seriesId: item.SeriesId,
seasonId: item.SeasonId,
season: item.GetSeasonNum(),
absolutEpisodeNumberE: epNum,
image: img,
imageBig: imgBig,
hslang: hslang,
availableSubs: item.SubtitleLocales,
selectedDubs: selectedDubs
);
}
var playback = item.Playback;
if (!string.IsNullOrEmpty(item.StreamsLink)){
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();
return retMeta;
} }
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){ public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){

View file

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll; namespace CRD.Downloader.Crunchyroll;

View file

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll.Music; using CRD.Utils.Structs.Crunchyroll.Music;

View file

@ -6,8 +6,10 @@ using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Views; using CRD.Views;
using ReactiveUI; using ReactiveUI;
@ -17,32 +19,44 @@ namespace CRD.Downloader.Crunchyroll;
public class CrSeries{ public class CrSeries{
private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance; private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance;
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){ public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? all, List<string>? e){
var ret = new Dictionary<string, CrunchyEpMeta>(); var ret = new Dictionary<string, CrunchyEpMeta>();
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
foreach (var kvp in eps){ var hslang = crunInstance.CrunOptions.Hslang;
var key = kvp.Key;
var episode = kvp.Value;
for (int index = 0; index < episode.Items.Count; index++){ bool ShouldInclude(string epNum) =>
var item = episode.Items[index]; all is true || (e != null && e.Contains(epNum));
if (item.IsPremiumOnly && !crunInstance.CrAuthEndpoint1.Profile.HasPremium){ foreach (var (key, episode) in eps){
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)); var epNum = key.StartsWith('E') ? key[1..] : key;
foreach (var v in episode.Variants){
var item = v.Item;
var lang = v.Lang;
item.SeqId = epNum;
if (item.IsPremiumOnly && !hasPremium){
MessageBus.Current.SendMessage(new ToastMessage(
"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription",
ToastType.Error, 3));
continue; continue;
} }
// history override
var effectiveDubs = dubLang;
if (crunInstance.CrunOptions.History){ if (crunInstance.CrunOptions.History){
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId); var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
if (dubLangList.Count > 0){ if (dubLangList.Count > 0)
dubLang = dubLangList; effectiveDubs = dubLangList;
}
} }
if (!dubLang.Contains(episode.Langs[index].CrLocale)) if (!effectiveDubs.Contains(lang.CrLocale))
continue; continue;
// season title fallbacks (same behavior)
item.HideSeasonTitle = true; item.HideSeasonTitle = true;
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){ if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
item.SeasonTitle = item.SeriesTitle; item.SeasonTitle = item.SeriesTitle;
@ -55,66 +69,66 @@ public class CrSeries{
item.SeriesTitle = "NO_TITLE"; item.SeriesTitle = "NO_TITLE";
} }
var epNum = key.StartsWith('E') ? key[1..] : key; // selection gate
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]); if (!ShouldInclude(epNum))
continue;
Regex dubPattern = new Regex(@"\(\w+ Dub\)"); // Create base queue item once per "key"
if (!ret.TryGetValue(key, out var qItem)){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episode.Variants.Select(x => (string?)x.Item.SeriesTitle));
var epMeta = new CrunchyEpMeta(); var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } }; episode.Variants.Select(x => (string?)x.Item.SeasonTitle));
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(); var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
epMeta.EpisodeNumber = item.Episode;
epMeta.EpisodeTitle = item.Title; var selectedDubs = effectiveDubs
epMeta.SeasonId = item.SeasonId; .Where(d => episode.Variants.Any(x => x.Lang.CrLocale == d))
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(); .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 epMetaData = epMeta.Data[0]; var playback = item.Playback;
if (!string.IsNullOrEmpty(item.StreamsLink)){ if (!string.IsNullOrEmpty(item.StreamsLink)){
epMetaData.Playback = item.StreamsLink; playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){ if (string.IsNullOrEmpty(item.Playback))
item.Playback = item.StreamsLink; item.Playback = item.StreamsLink;
}
} }
if (all is true || e != null && e.Contains(epNum)){ // Add variant
if (ret.TryGetValue(key, out var epMe)){ ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
epMetaData.Lang = episode.Langs[index]; mediaId: item.Id,
epMe.Data.Add(epMetaData); lang: lang,
} else{ playback: playback,
epMetaData.Lang = episode.Langs[index]; versions: item.Versions,
epMeta.Data[0] = epMetaData; isSubbed: item.IsSubbed,
ret.Add(key, epMeta); isDubbed: item.IsDubbed
} ));
}
// show ep
item.SeqId = epNum;
} }
} }
return ret; return ret;
} }
@ -124,64 +138,58 @@ public class CrSeries{
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale); CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
if (parsedSeries == null || parsedSeries.Data == null){ if (parsedSeries?.Data == null){
Console.Error.WriteLine("Parse Data Invalid"); Console.Error.WriteLine("Parse Data Invalid");
return null; return null;
} }
// var result = ParseSeriesResult(parsedSeries); var episodes = new Dictionary<string, EpisodeAndLanguage>();
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
if (crunInstance.CrunOptions.History){ if (crunInstance.CrunOptions.History)
_ = crunInstance.History.CrUpdateSeries(id, ""); _ = crunInstance.History.CrUpdateSeries(id, "");
}
var cachedSeasonId = ""; var cachedSeasonId = "";
var seasonData = new CrunchyEpisodeList(); var seasonData = new CrunchyEpisodeList();
foreach (var s in parsedSeries.Data){ 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; int fallbackIndex = 0;
if (cachedSeasonId != s.Id){ if (cachedSeasonId != s.Id){
seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : ""); seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : "");
cachedSeasonId = s.Id; cachedSeasonId = s.Id;
} }
if (seasonData.Data != null){ if (seasonData.Data == null)
foreach (var episode in seasonData.Data){ continue;
// Prepare the episode array
EpisodeAndLanguage item;
foreach (var episode in seasonData.Data){
string episodeNum =
(episode.Episode != string.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}"))
?? string.Empty;
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 seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}"; var episodeKey = $"{seasonIdentifier}E{episodeNum}";
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
if (!episodes.ContainsKey(episodeKey)){ if (!episodes.TryGetValue(episodeKey, out var item)){
item = new EpisodeAndLanguage{ item = new EpisodeAndLanguage(); // must have Variants
Items = new List<CrunchyEpisode>(), episodes[episodeKey] = item;
Langs = new List<LanguageItem>() }
};
episodes[episodeKey] = item; if (episode.Versions != null){
} else{ foreach (var version in episode.Versions){
item = episodes[episodeKey]; var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
} item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
if (episode.Versions != null){
foreach (var version in episode.Versions){
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem());
}
}
} else{
serieshasversions = false;
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem());
}
} }
} else{
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang);
} }
} }
} }
@ -198,22 +206,25 @@ public class CrSeries{
int specialIndex = 1; int specialIndex = 1;
int epIndex = 1; int epIndex = 1;
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating. var keys = new List<string>(episodes.Keys);
foreach (var key in keys){ foreach (var key in keys){
EpisodeAndLanguage item = episodes[key]; var item = episodes[key];
var episode = item.Items[0].Episode; if (item.Variants.Count == 0)
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special). continue;
// var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}";
var baseEp = item.Variants[0].Item;
var epStr = baseEp.Episode;
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
string newKey; string newKey;
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){ if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id); newKey = $"SP{specialIndex}_" + baseEp.Episode;
} else{ } else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}"; newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
} }
episodes.Remove(key); episodes.Remove(key);
int counter = 1; int counter = 1;
@ -225,63 +236,95 @@ public class CrSeries{
episodes.Add(newKey, item); episodes.Add(newKey, item);
if (isSpecial){ if (isSpecial) specialIndex++;
specialIndex++; else epIndex++;
} else{
epIndex++;
}
} }
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList(); var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList(); var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
// Combining and sorting episodes with normal first, then specials.
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials)); var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
foreach (var kvp in sortedEpisodes){ foreach (var kvp in sortedEpisodes){
var key = kvp.Key; var key = kvp.Key;
var item = kvp.Value; var item = kvp.Value;
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle if (item.Variants.Count == 0)
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); continue;
var title = item.Items[0].Title; var baseEp = item.Variants[0].Item;
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
var languages = item.Items.Select((a, index) => var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆ item.Variants.Select(string? (v) => v.Item.SeasonTitle)
);
var title = baseEp.Title;
var seasonNumber = Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString();
var languages = item.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]"); 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."); Console.WriteLine("Couldn\'t find versions on some episodes, added languages with language array.");
}
CrunchySeriesList crunchySeriesList = new CrunchySeriesList(); var crunchySeriesList = new CrunchySeriesList{
crunchySeriesList.Data = sortedEpisodes; Data = sortedEpisodes
};
crunchySeriesList.List = sortedEpisodes.Select(kvp => { crunchySeriesList.List = sortedEpisodes.Select(kvp => {
var key = kvp.Key; var key = kvp.Key;
var value = kvp.Value; var value = kvp.Value;
var images = (value.Items.FirstOrDefault()?.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
var seconds = (int)Math.Floor((value.Items.FirstOrDefault()?.DurationMs ?? 0) / 1000.0); if (value.Variants.Count == 0){
var langList = value.Langs.Select(a => a.CrLocale).ToList(); return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = new List<string>(),
Name = string.Empty,
Season = string.Empty,
SeriesTitle = string.Empty,
SeasonTitle = string.Empty,
EpisodeNum = key,
Id = string.Empty,
Img = string.Empty,
Description = string.Empty,
EpisodeType = EpisodeType.Episode,
Time = "0:00"
};
}
var baseEp = value.Variants[0].Item;
var thumbRow = baseEp.Images.Thumbnail.FirstOrDefault();
var img = thumbRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var seconds = (int)Math.Floor((baseEp.DurationMs) / 1000.0);
var langList = value.Variants
.Select(v => v.Lang.CrLocale)
.Distinct()
.ToList();
Languages.SortListByLangList(langList); Languages.SortListByLangList(langList);
return new Episode{ return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key, E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = langList, Lang = langList,
Name = value.Items.FirstOrDefault()?.Title ?? string.Empty, Name = baseEp.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(value.Items.FirstOrDefault()?.Identifier?? string.Empty) ?? value.Items.FirstOrDefault()?.SeasonNumber.ToString()) ?? string.Empty, Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeriesTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(), SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
SeasonTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeasonTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(), SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
EpisodeNum = key.StartsWith("SP") ? key : value.Items.FirstOrDefault()?.EpisodeNumber?.ToString() ?? value.Items.FirstOrDefault()?.Episode ?? "?", EpisodeNum = key.StartsWith("SP")
Id = value.Items.FirstOrDefault()?.SeasonId ?? string.Empty, ? key
Img = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty, : (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
Description = value.Items.FirstOrDefault()?.Description ?? string.Empty, Id = baseEp.SeasonId ?? string.Empty,
Img = img,
Description = baseEp.Description ?? string.Empty,
EpisodeType = EpisodeType.Episode, EpisodeType = EpisodeType.Episode,
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds. Time = $"{seconds / 60}:{seconds % 60:D2}"
}; };
}).ToList(); }).ToList();
@ -333,7 +376,7 @@ public class CrSeries{
Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}"); Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
} else{ } else{
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ??
new CrunchyEpisodeList(){ Data =[], Total = 0, Meta = new Meta() }; new CrunchyEpisodeList(){ Data = [], Total = 0, Meta = new Meta() };
} }
if (episodeList.Total < 1){ if (episodeList.Total < 1){
@ -456,7 +499,7 @@ public class CrSeries{
public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){ public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true); await crunInstance.CrAuthGuest.RefreshToken(true);
CrBrowseSeriesBase complete = new CrBrowseSeriesBase(); CrBrowseSeriesBase complete = new CrBrowseSeriesBase();
complete.Data =[]; complete.Data = [];
var i = 0; var i = 0;
@ -520,5 +563,4 @@ public class CrSeries{
return series; return series;
} }
} }

View file

@ -17,8 +17,14 @@ using CRD.Utils.DRM;
using CRD.Utils.Ffmpeg_Encoding; using CRD.Utils.Ffmpeg_Encoding;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.HLS; using CRD.Utils.HLS;
using CRD.Utils.Http;
using CRD.Utils.Muxing; using CRD.Utils.Muxing;
using CRD.Utils.Muxing.Fonts;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Muxing.Syncing;
using CRD.Utils.Parser;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.Utils.Sonarr.Models;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
@ -51,6 +57,7 @@ public class CrunchyrollManager{
public string DefaultLocale = "en-US"; public string DefaultLocale = "en-US";
public CrAuthSettings DefaultAndroidTvAuthSettings = new CrAuthSettings();
public CrAuthSettings DefaultAndroidAuthSettings = new CrAuthSettings(); public CrAuthSettings DefaultAndroidAuthSettings = new CrAuthSettings();
public CrAuthSettings GuestAndroidAuthSettings = new CrAuthSettings(); public CrAuthSettings GuestAndroidAuthSettings = new CrAuthSettings();
@ -152,6 +159,9 @@ public class CrunchyrollManager{
options.History = true; options.History = true;
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
options.HistoryAutoRefreshIntervalMinutes = 0;
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions); CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
return options; return options;
@ -205,6 +215,16 @@ public class CrunchyrollManager{
CfgManager.DisableLogMode(); CfgManager.DisableLogMode();
} }
DefaultAndroidTvAuthSettings = new CrAuthSettings(){
Endpoint = "tv/android_tv",
Authorization = "Basic eTJhcnZqYjBoMHJndnRpemxvdnk6SlZMdndkSXBYdnhVLXFJQnZUMU04b1FUcjFxbFFKWDI=",
UserAgent = "ANDROIDTV/3.59.0 Android/16",
Device_name = "Android TV",
Device_type = "Android TV",
Audio = true,
Video = true,
};
DefaultAndroidAuthSettings = new CrAuthSettings(){ DefaultAndroidAuthSettings = new CrAuthSettings(){
Endpoint = "android/phone", Endpoint = "android/phone",
Authorization = "Basic bzJhNndsamdub3FtdjloMWJ5bHI6Ujk3S3ExZm5faExZVFk0bDJxTjJIT2lDQnpfYnpBSUU=", Authorization = "Basic bzJhNndsamdub3FtdjloMWJ5bHI6Ujk3S3ExZm5faExZVFk0bDJxTjJIT2lDQnpfYnpBSUU=",
@ -215,19 +235,26 @@ public class CrunchyrollManager{
Video = true, Video = true,
}; };
CrunOptions.StreamEndpoint ??= new CrAuthSettings(){ Endpoint = "tv/android_tv", Audio = true, Video = true };
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
CrAuthEndpoint1.AuthSettings = new CrAuthSettings(){
Endpoint = "tv/android_tv",
Authorization = "Basic ZGsxYndzemRyc3lkeTR1N2xvenE6bDl0SU1BdTlzTGc4ZjA4ajlfQkQ4eWZmQmZTSms0R0o=",
UserAgent = "ANDROIDTV/3.47.0_22277 Android/16",
Device_name = "Android TV",
Device_type = "Android TV"
};
if (CrunOptions.StreamEndpoint == null){
CrunOptions.StreamEndpoint = DefaultAndroidTvAuthSettings;
} else if (CrunOptions.StreamEndpoint.UseDefault){
CrunOptions.StreamEndpoint.Authorization = DefaultAndroidTvAuthSettings.Authorization;
CrunOptions.StreamEndpoint.UserAgent = DefaultAndroidTvAuthSettings.UserAgent;
CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name;
CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type;
}
CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv";
CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint;
if (CrunOptions.StreamEndpointSecondSettings == null){ if (CrunOptions.StreamEndpointSecondSettings == null){
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings; CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
} else if (CrunOptions.StreamEndpointSecondSettings.UseDefault){
CrunOptions.StreamEndpointSecondSettings.Authorization = DefaultAndroidAuthSettings.Authorization;
CrunOptions.StreamEndpointSecondSettings.UserAgent = DefaultAndroidAuthSettings.UserAgent;
CrunOptions.StreamEndpointSecondSettings.Device_name = DefaultAndroidAuthSettings.Device_name;
CrunOptions.StreamEndpointSecondSettings.Device_type = DefaultAndroidAuthSettings.Device_type;
} }
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings; CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
@ -243,12 +270,6 @@ public class CrunchyrollManager{
CfgManager.WriteCrSettings(); CfgManager.WriteCrSettings();
// var token = await GetBase64EncodedTokenAsync();
//
// if (!string.IsNullOrEmpty(token)){
// ApiUrls.authBasicMob = "Basic " + token;
// }
var jsonFiles = Directory.Exists(CfgManager.PathENCODING_PRESETS_DIR) ? Directory.GetFiles(CfgManager.PathENCODING_PRESETS_DIR, "*.json") : []; var jsonFiles = Directory.Exists(CfgManager.PathENCODING_PRESETS_DIR) ? Directory.GetFiles(CfgManager.PathENCODING_PRESETS_DIR, "*.json") : [];
foreach (var file in jsonFiles){ foreach (var file in jsonFiles){
@ -305,7 +326,16 @@ public class CrunchyrollManager{
public async Task<bool> DownloadEpisode(CrunchyEpMeta data, CrDownloadOptions options){ public async Task<bool> DownloadEpisode(CrunchyEpMeta data, CrDownloadOptions options){
QueueManager.Instance.IncrementDownloads(); bool downloadSlotHeld = true;
bool processingSlotHeld = false;
void ReleaseDownloadSlotIfHeld(){
if (!downloadSlotHeld)
return;
downloadSlotHeld = false;
QueueManager.Instance.ReleaseDownloadSlot(data);
}
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, IsDownloading = true,
@ -326,7 +356,7 @@ public class CrunchyrollManager{
if (res.Error){ if (res.Error){
QueueManager.Instance.DecrementDownloads(); ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = false, IsDownloading = false,
Error = true, Error = true,
@ -341,7 +371,7 @@ public class CrunchyrollManager{
try{ try{
if (options.DownloadAllowEarlyStart){ if (options.DownloadAllowEarlyStart){
QueueManager.Instance.DecrementDownloads(); ReleaseDownloadSlotIfHeld();
data.DownloadProgress = new DownloadProgress(){ data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, IsDownloading = true,
Percent = 100, Percent = 100,
@ -350,7 +380,8 @@ public class CrunchyrollManager{
Doing = "Waiting for Muxing/Encoding" Doing = "Waiting for Muxing/Encoding"
}; };
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.Queue.Refresh();
await QueueManager.Instance.activeProcessingJobs.WaitAsync(data.Cts.Token); await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
processingSlotHeld = true;
} }
@ -394,8 +425,8 @@ public class CrunchyrollManager{
VideoTitle = res.VideoTitle, VideoTitle = res.VideoTitle,
Novids = options.Novids, Novids = options.Novids,
NoCleanup = options.Nocleanup, NoCleanup = options.Nocleanup,
DefaultAudio = Languages.FindLang(options.DefaultAudio), DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = Languages.FindLang(options.DefaultSub), DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions, MkvmergeOptions = options.MkvmergeOptions,
ForceMuxer = options.Force, ForceMuxer = options.Force,
SyncTiming = options.SyncTiming, SyncTiming = options.SyncTiming,
@ -439,11 +470,11 @@ public class CrunchyrollManager{
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty); var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null) await Helpers.RunFFmpegWithPresetAsync(merger.options.Output, preset, data); if (preset != null) await Helpers.RunFFmpegWithPresetAsync(merger.Options.Output, preset, data);
} }
if (options.DownloadToTempFolder){ if (options.DownloadToTempFolder){
await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, merger.options.Subtitles); await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, merger.Options.Subtitles);
} }
} }
} else{ } else{
@ -461,8 +492,8 @@ public class CrunchyrollManager{
VideoTitle = res.VideoTitle, VideoTitle = res.VideoTitle,
Novids = options.Novids, Novids = options.Novids,
NoCleanup = options.Nocleanup, NoCleanup = options.Nocleanup,
DefaultAudio = Languages.FindLang(options.DefaultAudio), DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = Languages.FindLang(options.DefaultSub), DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions, MkvmergeOptions = options.MkvmergeOptions,
ForceMuxer = options.Force, ForceMuxer = options.Force,
SyncTiming = options.SyncTiming, SyncTiming = options.SyncTiming,
@ -497,14 +528,14 @@ public class CrunchyrollManager{
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.Queue.Refresh();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty); var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.options.Output, preset, data); if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
} }
if (options.DownloadToTempFolder){ if (options.DownloadToTempFolder){
var tempFolder = res.TempFolderPath ?? CfgManager.PathTEMP_DIR; var tempFolder = res.TempFolderPath ?? CfgManager.PathTEMP_DIR;
List<SubtitleInput> subtitles = List<SubtitleInput> subtitles =
result.merger?.options.Subtitles result.merger?.Options.Subtitles
?? res.Data ?? res.Data
.Where(d => d.Type == DownloadMediaType.Subtitle) .Where(d => d.Type == DownloadMediaType.Subtitle)
.Select(d => new SubtitleInput{ .Select(d => new SubtitleInput{
@ -530,9 +561,7 @@ public class CrunchyrollManager{
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "")
}; };
if (CrunOptions.RemoveFinishedDownload && !syncError){ QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError);
QueueManager.Instance.Queue.Remove(data);
}
} else{ } else{
Console.WriteLine("Skipping mux"); Console.WriteLine("Skipping mux");
res.Data.ForEach(file => Helpers.DeleteFile(file.Path + ".resume")); res.Data.ForEach(file => Helpers.DeleteFile(file.Path + ".resume"));
@ -556,21 +585,19 @@ public class CrunchyrollManager{
Doing = "Done - Skipped muxing" Doing = "Done - Skipped muxing"
}; };
if (CrunOptions.RemoveFinishedDownload){ QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload);
QueueManager.Instance.Queue.Remove(data);
}
} }
} catch (OperationCanceledException){ } catch (OperationCanceledException){
// expected when removed/canceled // expected when removed/canceled
} finally{ } finally{
if (options.DownloadAllowEarlyStart) QueueManager.Instance.activeProcessingJobs.Release(); ReleaseDownloadSlotIfHeld();
if (processingSlotHeld){
QueueManager.Instance.ReleaseProcessingSlot();
}
} }
if (!options.DownloadAllowEarlyStart){
QueueManager.Instance.DecrementDownloads();
}
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.Queue.Refresh();
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){ if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
@ -582,16 +609,28 @@ public class CrunchyrollManager{
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId); _ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
} }
if (QueueManager.Instance.Queue.Count == 0 || QueueManager.Instance.Queue.All(e => e.DownloadProgress.Done)){ if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
QueueManager.Instance.ResetDownloads(); if (CrunOptions.DownloadFinishedPlaySound){
try{ try{
var audioPath = CrunOptions.DownloadFinishedSoundPath; var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){ if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer(); var player = new AudioPlayer();
player.Play(audioPath); player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
}
if (CrunOptions.DownloadFinishedExecute){
try{
var filePath = CrunOptions.DownloadFinishedExecutePath;
if (!string.IsNullOrEmpty(filePath)){
Helpers.ExecuteFile(filePath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to execute file: " + exception);
} }
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
} }
if (CrunOptions.ShutdownWhenQueueEmpty){ if (CrunOptions.ShutdownWhenQueueEmpty){
@ -599,6 +638,7 @@ public class CrunchyrollManager{
} }
} }
return true; return true;
} }
@ -623,7 +663,7 @@ public class CrunchyrollManager{
} }
// Move the main output file // Move the main output file
await MoveFile(merger?.options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options); await MoveFile(merger?.Options.Output ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
// Move the subtitle files // Move the subtitle files
foreach (var downloadedMedia in subtitles){ foreach (var downloadedMedia in subtitles){
@ -740,8 +780,8 @@ public class CrunchyrollManager{
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(), Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
VideoTitle = options.VideoTitle, VideoTitle = options.VideoTitle,
Options = new MuxOptions(){ Options = new MuxOptions(){
ffmpeg = options.FfmpegOptions, Ffmpeg = options.FfmpegOptions,
mkvmerge = options.MkvmergeOptions Mkvmerge = options.MkvmergeOptions
}, },
Defaults = new Defaults(){ Defaults = new Defaults(){
Audio = options.DefaultAudio, Audio = options.DefaultAudio,
@ -769,7 +809,7 @@ public class CrunchyrollManager{
List<string> notSyncedDubs = []; List<string> notSyncedDubs = [];
if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.options.OnlyVid.Count > 0 && merger.options.OnlyAudio.Count > 0){ if (options is{ SyncTiming: true, DlVideoOnce: true } && merger.Options.OnlyVid.Count > 0 && merger.Options.OnlyAudio.Count > 0){
crunchyEpMeta.DownloadProgress = new DownloadProgress(){ crunchyEpMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = true, IsDownloading = true,
Percent = 100, Percent = 100,
@ -780,30 +820,40 @@ public class CrunchyrollManager{
QueueManager.Instance.Queue.Refresh(); QueueManager.Instance.Queue.Refresh();
var basePath = merger.options.OnlyVid.First().Path; var basePath = merger.Options.OnlyVid.First().Path;
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList(); var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
if (!string.IsNullOrEmpty(basePath) && syncVideosList.Count > 0){ if (!string.IsNullOrEmpty(basePath) && syncVideosList.Count > 0){
foreach (var syncVideo in syncVideosList){ foreach (var syncVideo in syncVideosList){
if (!string.IsNullOrEmpty(syncVideo.Path)){ if (!string.IsNullOrEmpty(syncVideo.Path)){
var delay = await merger.ProcessVideo(basePath, syncVideo.Path); var delay = await VideoSyncer.ProcessVideo(basePath, syncVideo.Path);
if (delay <= -100){ if (delay.offSet <= -100){
syncError = true; syncError = true;
notSyncedDubs.Add(syncVideo.Lang.CrLocale ?? syncVideo.Language.CrLocale); notSyncedDubs.Add(syncVideo.Lang.CrLocale);
continue; continue;
} }
var audio = merger.options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale); if (delay.lengthDiff > 0.1){
if (audio != null){ Console.Error.WriteLine($"Dub difference > 0.1:");
audio.Delay = (int)(delay * 1000); Console.Error.WriteLine($"\tFor episode: {filename}");
Console.Error.WriteLine($"\tFor dub: {syncVideo.Lang.CrLocale}");
Console.Error.WriteLine($"\tStart offset: {delay.startOffset} seconds");
Console.Error.WriteLine($"\tEnd offset: {delay.endOffset} seconds");
Console.Error.WriteLine($"\tVideo length difference: {delay.lengthDiff} seconds");
} }
var subtitles = merger.options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
if (subtitles.Count > 0){ var audio = merger.Options.OnlyAudio.FirstOrDefault(audio => audio.Language.CrLocale == syncVideo.Lang.CrLocale);
foreach (var subMergerInput in subtitles){ audio?.Delay = (int)(delay.offSet * 1000);
subMergerInput.Delay = (int)(delay * 1000);
}
var subtitles = merger.Options.Subtitles.Where(a => a.RelatedVideoDownloadMedia == syncVideo).ToList();
if (subtitles.Count <= 0) continue;
foreach (var subMergerInput in subtitles){
subMergerInput.Delay = (int)(delay.offSet * 1000);
} }
} }
} }
@ -941,6 +991,16 @@ public class CrunchyrollManager{
if (data.Data is{ Count: > 0 }){ if (data.Data is{ Count: > 0 }){
options.Partsize = options.Partsize > 0 ? options.Partsize : 1; options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty ,data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty);
SonarrEpisode? sonarrEpisode;
if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){
sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId));
if(sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId);
variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true));
variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true));
variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true));
}
if (options.DownloadDescriptionAudio){ if (options.DownloadDescriptionAudio){
var alreadyAdr = new HashSet<string>( var alreadyAdr = new HashSet<string>(
data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err") data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err")
@ -1386,7 +1446,7 @@ public class CrunchyrollManager{
try{ try{
var entry = curStreams.Value; var entry = curStreams.Value;
MPDParsed streamPlaylists = MPDParser.Parse(entry.Playlist, Languages.FindLang(crLocal), matchedUrl); MPDParsed streamPlaylists = await MpdParser.Parse(entry.Playlist, Languages.FindLang(crLocal), matchedUrl);
streamServers.UnionWith(streamPlaylists.Data.Keys); streamServers.UnionWith(streamPlaylists.Data.Keys);
Helpers.MergePlaylistData(playListData, streamPlaylists.Data, entry.Audio, entry.Video); Helpers.MergePlaylistData(playListData, streamPlaylists.Data, entry.Audio, entry.Video);
} catch (Exception e){ } catch (Exception e){
@ -1551,7 +1611,9 @@ public class CrunchyrollManager{
string qualityConsoleLog = sb.ToString(); string qualityConsoleLog = sb.ToString();
Console.WriteLine(qualityConsoleLog); Console.WriteLine(qualityConsoleLog);
data.AvailableQualities = qualityConsoleLog; if (!options.DlVideoOnce || string.IsNullOrEmpty(data.AvailableQualities)){
data.AvailableQualities = qualityConsoleLog;
}
Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]); Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]);
@ -2186,7 +2248,7 @@ public class CrunchyrollManager{
SubtitleUtils.CleanAssAndEnsureScriptInfo(subsAssReqResponse.ResponseContent, options, langItem); SubtitleUtils.CleanAssAndEnsureScriptInfo(subsAssReqResponse.ResponseContent, options, langItem);
sxData.Title = $"{langItem.Name}"; sxData.Title = $"{langItem.Name}";
var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent); var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent, options.MuxTypesettingFonts);
sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList); sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList);
} else if (subsItem.format == "vtt" && options.ConvertVtt2Ass){ } else if (subsItem.format == "vtt" && options.ConvertVtt2Ass){
var assBuilder = new StringBuilder(); var assBuilder = new StringBuilder();
@ -2264,7 +2326,7 @@ public class CrunchyrollManager{
subsAssReqResponse.ResponseContent = assBuilder.ToString(); subsAssReqResponse.ResponseContent = assBuilder.ToString();
sxData.Title = $"{langItem.Name} / CC Subtitle"; sxData.Title = $"{langItem.Name} / CC Subtitle";
var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent); var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent, options.MuxTypesettingFonts);
sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList); sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList);
sxData.Path = sxData.Path.Replace("vtt", "ass"); sxData.Path = sxData.Path.Replace("vtt", "ass");
} }

View file

@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class DownloadQueueItemFactory{
private static readonly Regex DubSuffix = new(@"\(\w+ Dub\)", RegexOptions.Compiled);
public static bool HasDubSuffix(string? s)
=> !string.IsNullOrWhiteSpace(s) && DubSuffix.IsMatch(s);
public static string StripDubSuffix(string? s)
=> string.IsNullOrWhiteSpace(s) ? "" : DubSuffix.Replace(s, "").TrimEnd();
public static string CanonicalTitle(IEnumerable<string?> candidates){
var noDub = candidates.FirstOrDefault(t => !HasDubSuffix(t));
return !string.IsNullOrWhiteSpace(noDub)
? noDub!
: StripDubSuffix(candidates.FirstOrDefault());
}
public static (string small, string big) GetThumbSmallBig(Images? images){
var firstRow = images?.Thumbnail?.FirstOrDefault();
var small = firstRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var big = firstRow?.LastOrDefault()?.Source ?? small;
return (small, big);
}
public static CrunchyEpMeta CreateShell(
StreamingService service,
string? seriesTitle,
string? seasonTitle,
string? episodeNumber,
string? episodeTitle,
string? description,
string? episodeId,
string? seriesId,
string? seasonId,
string? season,
string? absolutEpisodeNumberE,
string? image,
string? imageBig,
string hslang,
List<string>? availableSubs = null,
List<string>? selectedDubs = null,
bool music = false){
return new CrunchyEpMeta(){
SeriesTitle = seriesTitle,
SeasonTitle = seasonTitle,
EpisodeNumber = episodeNumber,
EpisodeTitle = episodeTitle,
Description = description,
EpisodeId = episodeId,
SeriesId = seriesId,
SeasonId = seasonId,
Season = season,
AbsolutEpisodeNumberE = absolutEpisodeNumberE,
Image = image,
ImageBig = imageBig,
Hslang = hslang,
AvailableSubs = availableSubs,
SelectedDubs = selectedDubs,
Music = music
};
}
public static CrunchyEpMetaData CreateVariant(
string mediaId,
LanguageItem? lang,
string? playback,
List<EpisodeVersion>? versions,
bool isSubbed,
bool isDubbed,
bool isAudioRoleDescription = false){
return new CrunchyEpMetaData{
MediaId = mediaId,
Lang = lang,
Playback = playback,
Versions = versions,
IsSubbed = isSubbed,
IsDubbed = isDubbed,
IsAudioRoleDescription = isAudioRoleDescription
};
}
}

View file

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class EpisodeMapper{
public static CrunchyEpisode ToCrunchyEpisode(this CrBrowseEpisode src){
if (src == null) throw new ArgumentNullException(nameof(src));
var meta = src.EpisodeMetadata ?? new CrBrowseEpisodeMetaData();
return new CrunchyEpisode{
Id = src.Id ?? string.Empty,
Slug = src.Slug ?? string.Empty,
SlugTitle = src.SlugTitle ?? string.Empty,
Title = src.Title ?? string.Empty,
Description = src.Description ?? src.PromoDescription ?? string.Empty,
MediaType = src.Type,
ChannelId = src.ChannelId,
StreamsLink = src.StreamsLink,
Images = src.Images ?? new Images(),
SeoTitle = src.PromoTitle ?? string.Empty,
SeoDescription = src.PromoDescription ?? string.Empty,
ProductionEpisodeId = src.ExternalId ?? string.Empty,
ListingId = src.LinkedResourceKey ?? string.Empty,
SeriesId = meta.SeriesId ?? string.Empty,
SeasonId = meta.SeasonId ?? string.Empty,
SeriesTitle = meta.SeriesTitle ?? string.Empty,
SeriesSlugTitle = meta.SeriesSlugTitle ?? string.Empty,
SeasonTitle = meta.SeasonTitle ?? string.Empty,
SeasonSlugTitle = meta.SeasonSlugTitle ?? string.Empty,
SeasonNumber = SafeInt(meta.SeasonNumber),
SequenceNumber = (float)meta.SequenceNumber,
Episode = meta.Episode,
EpisodeNumber = meta.EpisodeCount,
DurationMs = meta.DurationMs,
Identifier = meta.Identifier ?? string.Empty,
AvailabilityNotes = meta.AvailabilityNotes ?? string.Empty,
EligibleRegion = meta.EligibleRegion ?? string.Empty,
AvailabilityStarts = meta.AvailabilityStarts,
AvailabilityEnds = meta.AvailabilityEnds,
PremiumAvailableDate = meta.PremiumAvailableDate,
FreeAvailableDate = meta.FreeAvailableDate,
AvailableDate = meta.AvailableDate,
PremiumDate = meta.PremiumDate,
UploadDate = meta.UploadDate,
EpisodeAirDate = meta.EpisodeAirDate,
IsDubbed = meta.IsDubbed,
IsSubbed = meta.IsSubbed,
IsMature = meta.IsMature,
IsClip = meta.IsClip,
IsPremiumOnly = meta.IsPremiumOnly,
MatureBlocked = meta.MatureBlocked,
AvailableOffline = meta.AvailableOffline,
ClosedCaptionsAvailable = meta.ClosedCaptionsAvailable,
MaturityRatings = meta.MaturityRatings ?? new List<string>(),
AudioLocale = (meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
SubtitleLocales = (meta.SubtitleLocales ?? new List<Locale>())
.Select(l => l.GetEnumMemberValue())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
ExtendedMaturityRating = ToStringKeyDict(meta.ExtendedMaturityRating),
Versions = meta.versions?.Select(ToEpisodeVersion).ToList()
};
}
private static EpisodeVersion ToEpisodeVersion(CrBrowseEpisodeVersion v){
return new EpisodeVersion{
AudioLocale = (v.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
Guid = v.Guid ?? string.Empty,
Original = v.Original,
Variant = v.Variant ?? string.Empty,
SeasonGuid = v.SeasonGuid ?? string.Empty,
MediaGuid = v.MediaGuid,
IsPremiumOnly = v.IsPremiumOnly,
roles = Array.Empty<string>()
};
}
private static int SafeInt(double value){
if (double.IsNaN(value) || double.IsInfinity(value)) return 0;
return (int)Math.Round(value, MidpointRounding.AwayFromZero);
}
private static Dictionary<string, object> ToStringKeyDict(Dictionary<object, object>? dict){
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (dict == null) return result;
foreach (var kv in dict){
var key = kv.Key?.ToString();
if (string.IsNullOrWhiteSpace(key)) continue;
result[key] = kv.Value ?? new object();
}
return result;
}
}

View file

@ -80,7 +80,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private bool _muxToMp3; private bool _muxToMp3;
[ObservableProperty] [ObservableProperty]
private bool _muxFonts; private bool muxFonts;
[ObservableProperty]
private bool muxTypesettingFonts;
[ObservableProperty] [ObservableProperty]
private bool _muxCover; private bool _muxCover;
@ -155,10 +158,28 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private ComboBoxItem _selectedStreamEndpoint; private ComboBoxItem _selectedStreamEndpoint;
[ObservableProperty] [ObservableProperty]
private bool _firstEndpointVideo; private bool firstEndpointVideo;
[ObservableProperty] [ObservableProperty]
private bool _firstEndpointAudio; private bool firstEndpointAudio;
[ObservableProperty]
private bool firstEndpointUseDefault;
[ObservableProperty]
private string firstEndpointAuthorization = "";
[ObservableProperty]
private string firstEndpointUserAgent = "";
[ObservableProperty]
private string firstEndpointDeviceName = "";
[ObservableProperty]
private string firstEndpointDeviceType = "";
[ObservableProperty]
private bool firstEndpointNotSignedWarning;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem _SelectedStreamEndpointSecondary; private ComboBoxItem _SelectedStreamEndpointSecondary;
@ -176,16 +197,19 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private string _endpointDeviceType = ""; private string _endpointDeviceType = "";
[ObservableProperty] [ObservableProperty]
private bool _endpointVideo; private bool endpointVideo;
[ObservableProperty] [ObservableProperty]
private bool _endpointAudio; private bool endpointAudio;
[ObservableProperty]
private bool endpointUseDefault;
[ObservableProperty] [ObservableProperty]
private bool _isLoggingIn; private bool _isLoggingIn;
[ObservableProperty] [ObservableProperty]
private bool _endpointNotSignedWarning; private bool endpointNotSignedWarning;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem _selectedDefaultDubLang; private ComboBoxItem _selectedDefaultDubLang;
@ -246,9 +270,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public ObservableCollection<ListBoxItem> DubLangList{ get; } = []; public ObservableCollection<ListBoxItem> DubLangList{ get; } = [];
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = []; public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [
new(){ Content = "none" },
];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = []; public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [
new(){ Content = "none" },
];
public ObservableCollection<ListBoxItem> SubLangList{ get; } =[ public ObservableCollection<ListBoxItem> SubLangList{ get; } =[
@ -369,14 +397,25 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
EndpointDeviceType = options.StreamEndpointSecondSettings?.Device_type ?? string.Empty; EndpointDeviceType = options.StreamEndpointSecondSettings?.Device_type ?? string.Empty;
EndpointVideo = options.StreamEndpointSecondSettings?.Video ?? true; EndpointVideo = options.StreamEndpointSecondSettings?.Video ?? true;
EndpointAudio = options.StreamEndpointSecondSettings?.Audio ?? true; EndpointAudio = options.StreamEndpointSecondSettings?.Audio ?? true;
EndpointUseDefault = options.StreamEndpointSecondSettings?.UseDefault ?? true;
FirstEndpointVideo = options.StreamEndpoint?.Video ?? true; FirstEndpointVideo = options.StreamEndpoint?.Video ?? true;
FirstEndpointAudio = options.StreamEndpoint?.Audio ?? 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 == "???"){ if (CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???"){
EndpointNotSignedWarning = true; EndpointNotSignedWarning = true;
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???"){
FirstEndpointNotSignedWarning = true;
}
FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions()); FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions());
if (FFmpegHWAccel.Count == 0){ if (FFmpegHWAccel.Count == 0){
@ -441,6 +480,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
MuxToMp4 = options.Mp4; MuxToMp4 = options.Mp4;
MuxToMp3 = options.AudioOnlyToMp3; MuxToMp3 = options.AudioOnlyToMp3;
MuxFonts = options.MuxFonts; MuxFonts = options.MuxFonts;
MuxTypesettingFonts = options.MuxTypesettingFonts;
MuxCover = options.MuxCover; MuxCover = options.MuxCover;
SyncTimings = options.SyncTiming; SyncTimings = options.SyncTiming;
SkipSubMux = options.SkipSubsMux; SkipSubMux = options.SkipSubsMux;
@ -516,6 +556,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.Mp4 = MuxToMp4; CrunchyrollManager.Instance.CrunOptions.Mp4 = MuxToMp4;
CrunchyrollManager.Instance.CrunOptions.AudioOnlyToMp3 = MuxToMp3; CrunchyrollManager.Instance.CrunOptions.AudioOnlyToMp3 = MuxToMp3;
CrunchyrollManager.Instance.CrunOptions.MuxFonts = MuxFonts; CrunchyrollManager.Instance.CrunOptions.MuxFonts = MuxFonts;
CrunchyrollManager.Instance.CrunOptions.MuxTypesettingFonts = MuxTypesettingFonts;
CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover; CrunchyrollManager.Instance.CrunOptions.MuxCover = MuxCover;
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings; CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux; CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
@ -552,6 +593,11 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
endpointSettingsFirst.Endpoint = SelectedStreamEndpoint.Content + ""; endpointSettingsFirst.Endpoint = SelectedStreamEndpoint.Content + "";
endpointSettingsFirst.Video = FirstEndpointVideo; endpointSettingsFirst.Video = FirstEndpointVideo;
endpointSettingsFirst.Audio = FirstEndpointAudio; 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; CrunchyrollManager.Instance.CrunOptions.StreamEndpoint = endpointSettingsFirst;
var endpointSettings = new CrAuthSettings(); var endpointSettings = new CrAuthSettings();
@ -562,6 +608,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
endpointSettings.Device_type = EndpointDeviceType; endpointSettings.Device_type = EndpointDeviceType;
endpointSettings.Video = EndpointVideo; endpointSettings.Video = EndpointVideo;
endpointSettings.Audio = EndpointAudio; endpointSettings.Audio = EndpointAudio;
endpointSettings.UseDefault = EndpointUseDefault;
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings; CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings;
@ -728,13 +775,33 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public void ResetEndpointSettings(){ public void ResetEndpointSettings(){
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == ("android/phone")) ?? null; var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidAuthSettings;
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0]; SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
EndpointAuthorization = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Authorization; EndpointAuthorization = defaultSettings.Authorization;
EndpointUserAgent = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.UserAgent; EndpointUserAgent = defaultSettings.UserAgent;
EndpointDeviceName = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_name; EndpointDeviceName = defaultSettings.Device_name;
EndpointDeviceType = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_type; EndpointDeviceType = defaultSettings.Device_type;
UpdateSettings();
}
[RelayCommand]
public void ResetFirstEndpointSettings(){
var defaultSettings = CrunchyrollManager.Instance.DefaultAndroidTvAuthSettings;
ComboBoxItem? streamEndpointSecondar = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == defaultSettings.Endpoint) ?? null;
SelectedStreamEndpoint = streamEndpointSecondar ?? StreamEndpoints[0];
FirstEndpointAuthorization = defaultSettings.Authorization;
FirstEndpointUserAgent = defaultSettings.UserAgent;
FirstEndpointDeviceName = defaultSettings.Device_name;
FirstEndpointDeviceType = defaultSettings.Device_type;
UpdateSettings();
} }
[RelayCommand] [RelayCommand]
@ -755,6 +822,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
await viewModel.LoginCompleted; await viewModel.LoginCompleted;
IsLoggingIn = false; IsLoggingIn = false;
EndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???"; EndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???";
FirstEndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???";
} }
private List<StringItemWithDisplayName> GetAvailableHWAccelOptions(){ private List<StringItemWithDisplayName> GetAvailableHWAccelOptions(){

View file

@ -264,16 +264,68 @@
</ComboBox> </ComboBox>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/> <TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center" <CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding FirstEndpointVideo}" /> IsChecked="{Binding FirstEndpointVideo}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/> <TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center" <CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding FirstEndpointAudio}" /> IsChecked="{Binding FirstEndpointAudio}" />
</StackPanel> </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> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
@ -288,35 +340,41 @@
</ComboBox> </ComboBox>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/> <TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center" <CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding EndpointVideo}" /> IsChecked="{Binding EndpointVideo}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/> <TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center" <CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding EndpointAudio}" /> IsChecked="{Binding EndpointAudio}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5"> <StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Use Default" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center" />
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding EndpointUseDefault}" />
</StackPanel>
<StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<TextBlock Text="Authorization" /> <TextBlock Text="Authorization" />
<TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap" <TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointAuthorization}" /> Text="{Binding EndpointAuthorization}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5"> <StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<TextBlock Text="User Agent" /> <TextBlock Text="User Agent" />
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap" <TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointUserAgent}" /> Text="{Binding EndpointUserAgent}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5"> <StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<TextBlock Text="Device Type" /> <TextBlock Text="Device Type" />
<TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap" <TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceType}" /> Text="{Binding EndpointDeviceType}" />
</StackPanel> </StackPanel>
<StackPanel Margin="0,5"> <StackPanel Margin="0,5" IsEnabled="{Binding !EndpointUseDefault}">
<TextBlock Text="Device Name" /> <TextBlock Text="Device Name" />
<TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap" <TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceName}" /> Text="{Binding EndpointDeviceName}" />
@ -324,6 +382,7 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Button Margin="5 10" VerticalAlignment="Center" <Button Margin="5 10" VerticalAlignment="Center"
IsEnabled="{Binding !EndpointUseDefault}"
Command="{Binding ResetEndpointSettings}"> Command="{Binding ResetEndpointSettings}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center"> <StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<TextBlock Text="Reset" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" /> <TextBlock Text="Reset" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" />
@ -448,7 +507,7 @@
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Filename" <controls:SettingsExpanderItem Content="Filename"
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\"> Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs} - Folder with \\ &#10;Sonarr: ${sonarrSeriesTitle} ${sonarrSeriesReleaseYear} ${sonarrEpisodeTitle}">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<TextBox Name="FileNameTextBox" HorizontalAlignment="Left" MinWidth="250" <TextBox Name="FileNameTextBox" HorizontalAlignment="Left" MinWidth="250"
Text="{Binding FileName}" /> Text="{Binding FileName}" />
@ -521,7 +580,12 @@
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Include Fonts" Description="Includes the fonts in the mkv"> <controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Include Fonts" Description="Includes the fonts in the mkv">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MuxFonts}"> </CheckBox>
<StackPanel>
<CheckBox IsChecked="{Binding MuxFonts}" Content="Mux Fonts"> </CheckBox>
<CheckBox IsEnabled="{Binding MuxFonts}" IsChecked="{Binding MuxTypesettingFonts}" Content="Include Typesetting Fonts"> </CheckBox>
</StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </controls:SettingsExpanderItem>

View file

@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
@ -129,6 +130,129 @@ public class History{
} }
} }
public async Task UpdateWithEpisode(List<CrBrowseEpisode> episodes){
var historyIndex = crunInstance.HistoryList
.Where(h => !string.IsNullOrWhiteSpace(h.SeriesId))
.ToDictionary(
h => h.SeriesId!,
h => (h.Seasons)
.Where(s => !string.IsNullOrWhiteSpace(s.SeasonId))
.ToDictionary(
s => s.SeasonId ?? "UNKNOWN",
s => (s.EpisodesList)
.Select(ep => ep.EpisodeId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal),
StringComparer.Ordinal
),
StringComparer.Ordinal
);
episodes = episodes
.Where(e => !string.IsNullOrWhiteSpace(e.EpisodeMetadata?.SeriesId) &&
historyIndex.ContainsKey(e.EpisodeMetadata!.SeriesId!))
.ToList();
foreach (var seriesGroup in episodes.GroupBy(e => e.EpisodeMetadata?.SeriesId ?? "UNKNOWN_SERIES")){
var seriesId = seriesGroup.Key;
var originalEntries = seriesGroup
.Select(e => new{ OriginalId = TryGetOriginalId(e), SeasonId = TryGetOriginalSeasonId(e) })
.Where(x => !string.IsNullOrWhiteSpace(x.OriginalId))
.GroupBy(x => x.OriginalId!, StringComparer.Ordinal)
.Select(g => new{
OriginalId = g.Key,
SeasonId = g.Select(x => x.SeasonId).FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
})
.ToList();
var hasAnyOriginalInfo = originalEntries.Count > 0;
var allOriginalsInHistory =
hasAnyOriginalInfo
&& originalEntries.All(x => IsOriginalInHistory(historyIndex, seriesId, x.SeasonId, x.OriginalId));
var originalItems = seriesGroup.Where(IsOriginalItem).ToList();
if (originalItems.Count > 0){
if (allOriginalsInHistory){
var sT = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} {sT} - all ORIGINAL episodes already in history.");
continue;
}
var convertedList = originalItems.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
continue;
}
var seriesTitle = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
if (allOriginalsInHistory){
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} - originals implied by Versions already in history.");
continue;
}
Console.WriteLine($"[WARN] No original ITEM found for SeriesId={seriesId} {seriesTitle}");
if (HasAllSeriesEpisodesInHistory(historyIndex, seriesId, seriesGroup)){
Console.WriteLine($"[History] Skip (already in history): {seriesId}");
} else{
await CrUpdateSeries(seriesId, null);
Console.WriteLine($"[History] Updating (full series): {seriesId}");
}
}
return;
}
private string? TryGetOriginalId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.Guid))
?.Guid;
private string? TryGetOriginalSeasonId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.SeasonGuid))
?.SeasonGuid
?? e.EpisodeMetadata?.SeasonId;
private bool IsOriginalItem(CrBrowseEpisode e){
var originalId = TryGetOriginalId(e);
return !string.IsNullOrWhiteSpace(originalId)
&& !string.IsNullOrWhiteSpace(e.Id)
&& string.Equals(e.Id, originalId, StringComparison.Ordinal);
}
private bool IsOriginalInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, string? seasonId, string originalEpisodeId){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
if (!string.IsNullOrWhiteSpace(seasonId))
return seasons.TryGetValue(seasonId, out var eps) && eps.Contains(originalEpisodeId);
return seasons.Values.Any(eps => eps.Contains(originalEpisodeId));
}
private bool HasAllSeriesEpisodesInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, IEnumerable<CrBrowseEpisode> seriesEpisodes){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
var allHistoryEpisodeIds = seasons.Values
.SelectMany(set => set)
.ToHashSet(StringComparer.Ordinal);
foreach (var e in seriesEpisodes){
if (string.IsNullOrWhiteSpace(e.Id)) return false;
if (!allHistoryEpisodeIds.Contains(e.Id)) return false;
}
return true;
}
/// <summary> /// <summary>
/// This method updates the History with a list of episodes. The episodes have to be from the same season. /// This method updates the History with a list of episodes. The episodes have to be from the same season.
/// </summary> /// </summary>
@ -206,7 +330,7 @@ public class History{
historySeries = new HistorySeries{ historySeries = new HistorySeries{
SeriesTitle = firstEpisode.GetSeriesTitle(), SeriesTitle = firstEpisode.GetSeriesTitle(),
SeriesId = firstEpisode.GetSeriesId(), SeriesId = firstEpisode.GetSeriesId(),
Seasons =[], Seasons = [],
HistorySeriesAddDate = DateTime.Now, HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(), SeriesType = firstEpisode.GetSeriesType(),
SeriesStreamingService = StreamingService.Crunchyroll SeriesStreamingService = StreamingService.Crunchyroll
@ -253,21 +377,10 @@ public class History{
} }
public HistoryEpisode? GetHistoryEpisode(string? seriesId, string? seasonId, string episodeId){ public HistoryEpisode? GetHistoryEpisode(string? seriesId, string? seasonId, string episodeId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); return CrunchyrollManager.Instance.HistoryList
.FirstOrDefault(series => series.SeriesId == seriesId)?
if (historySeries != null){ .Seasons.FirstOrDefault(season => season.SeasonId == seasonId)?
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId); .EpisodesList.Find(e => e.EpisodeId == episodeId);
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){ public (HistoryEpisode? historyEpisode, string downloadDirPath) GetHistoryEpisodeWithDownloadDir(string? seriesId, string? seasonId, string episodeId){
@ -302,8 +415,8 @@ public class History{
var downloadDirPath = ""; var downloadDirPath = "";
var videoQuality = ""; var videoQuality = "";
List<string> dublist =[]; List<string> dublist = [];
List<string> sublist =[]; List<string> sublist = [];
if (historySeries != null){ if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId); var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -353,7 +466,7 @@ public class History{
public List<string> GetDubList(string? seriesId, string? seasonId){ public List<string> GetDubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> dublist =[]; List<string> dublist = [];
if (historySeries != null){ if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId); var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -372,7 +485,7 @@ public class History{
public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){ public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> sublist =[]; List<string> sublist = [];
var videoQuality = ""; var videoQuality = "";
if (historySeries != null){ if (historySeries != null){
@ -430,8 +543,8 @@ public class History{
SeriesId = artisteData.Id, SeriesId = artisteData.Id,
SeriesTitle = artisteData.Name ?? "", SeriesTitle = artisteData.Name ?? "",
ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "", ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "",
HistorySeriesAvailableDubLang =[], HistorySeriesAvailableDubLang = [],
HistorySeriesAvailableSoftSubs =[] HistorySeriesAvailableSoftSubs = []
}; };
historySeries.SeriesDescription = cachedSeries.SeriesDescription; historySeries.SeriesDescription = cachedSeries.SeriesDescription;
@ -563,7 +676,7 @@ public class History{
SeasonTitle = firstEpisode.GetSeasonTitle(), SeasonTitle = firstEpisode.GetSeasonTitle(),
SeasonId = firstEpisode.GetSeasonId(), SeasonId = firstEpisode.GetSeasonId(),
SeasonNum = firstEpisode.GetSeasonNum(), SeasonNum = firstEpisode.GetSeasonNum(),
EpisodesList =[], EpisodesList = [],
SpecialSeason = firstEpisode.IsSpecialSeason() SpecialSeason = firstEpisode.IsSpecialSeason()
}; };
@ -631,7 +744,7 @@ public class History{
historySeries.SonarrNextAirDate = GetNextAirDate(episodes); historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
List<HistoryEpisode> allHistoryEpisodes =[]; List<HistoryEpisode> allHistoryEpisodes = [];
foreach (var historySeriesSeason in historySeries.Seasons){ foreach (var historySeriesSeason in historySeries.Seasons){
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList); allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
@ -659,7 +772,7 @@ public class History{
.ToList(); .ToList();
} }
List<HistoryEpisode> failedEpisodes =[]; List<HistoryEpisode> failedEpisodes = [];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => { Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){ if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){

View file

@ -3,61 +3,45 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Utils.Updater; using CRD.Utils.Updater;
using FluentAvalonia.Styling; using FluentAvalonia.Styling;
using ProtoBuf.Meta;
namespace CRD.Downloader; namespace CRD.Downloader;
public partial class ProgramManager : ObservableObject{ public sealed partial class ProgramManager : ObservableObject{
#region Singelton
private static ProgramManager? _instance; public static ProgramManager Instance{ get; } = new();
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 #region Observables
[ObservableProperty] [ObservableProperty]
private bool _fetchingData; private bool fetchingData;
[ObservableProperty] [ObservableProperty]
private bool _updateAvailable = true; private bool updateAvailable = true;
[ObservableProperty] [ObservableProperty]
private double _opacityButton = 0.4; private bool finishedLoading;
[ObservableProperty] [ObservableProperty]
private bool _finishedLoading; private bool navigationLock;
[ObservableProperty]
private bool _navigationLock;
#endregion #endregion
@ -75,10 +59,12 @@ public partial class ProgramManager : ObservableObject{
#endregion #endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
public IStorageProvider StorageProvider; public IStorageProvider? StorageProvider;
public ProgramManager(){ public ProgramManager(){
checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); });
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme; _faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme;
foreach (var arg in Environment.GetCommandLineArgs()){ foreach (var arg in Environment.GetCommandLineArgs()){
@ -106,12 +92,12 @@ public partial class ProgramManager : ObservableObject{
} }
} }
Init(); _ = Init();
CleanUpOldUpdater(); CleanUpOldUpdater();
} }
private async Task RefreshHistory(FilterType filterType){ internal async Task RefreshHistory(FilterType filterType){
FetchingData = true; FetchingData = true;
@ -172,12 +158,60 @@ public partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){ while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
Console.WriteLine("Waiting for downloads to complete..."); Console.WriteLine("Waiting for downloads to complete...");
await Task.Delay(2000); 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(){ public void SetBackgroundImage(){
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){ if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){
Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity, Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity,
@ -186,33 +220,39 @@ public partial class ProgramManager : ObservableObject{
} }
private async Task Init(){ private async Task Init(){
CrunchyrollManager.Instance.InitOptions(); try{
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync(); UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
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);
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
} }
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
}
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
StartRunners(true);
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
NavigationLock = false;
} }
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
} }
@ -230,7 +270,7 @@ public partial class ProgramManager : ObservableObject{
if (exitOnTaskFinish){ if (exitOnTaskFinish){
Console.WriteLine("Exiting..."); Console.WriteLine("Exiting...");
IClassicDesktopStyleApplicationLifetime? lifetime = (IClassicDesktopStyleApplicationLifetime)Application.Current?.ApplicationLifetime; IClassicDesktopStyleApplicationLifetime? lifetime = (IClassicDesktopStyleApplicationLifetime?)Application.Current?.ApplicationLifetime;
if (lifetime != null){ if (lifetime != null){
lifetime.Shutdown(); lifetime.Shutdown();
} else{ } else{
@ -239,7 +279,6 @@ public partial class ProgramManager : ObservableObject{
} }
} }
private void CleanUpOldUpdater(){ private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -256,4 +295,17 @@ public partial class ProgramManager : ObservableObject{
Console.WriteLine("No old updater file found to delete."); 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

@ -17,64 +17,141 @@ using ReactiveUI;
namespace CRD.Downloader; namespace CRD.Downloader;
public partial class QueueManager : ObservableObject{ public sealed partial class QueueManager : ObservableObject{
#region Download Variables
public RefreshableObservableCollection<CrunchyEpMeta> Queue = new RefreshableObservableCollection<CrunchyEpMeta>(); public static QueueManager Instance{ get; } = new();
public ObservableCollection<DownloadItemModel> DownloadItemModels = new ObservableCollection<DownloadItemModel>();
private int activeDownloads;
public int ActiveDownloads => Volatile.Read(ref activeDownloads); #region Download Variables
public readonly SemaphoreSlim activeProcessingJobs = new SemaphoreSlim(initialCount: CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs, maxCount: int.MaxValue); public RefreshableObservableCollection<CrunchyEpMeta> Queue{ get; } = new();
private int _limit = CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs; public ObservableCollection<DownloadItemModel> DownloadItemModels{ get; } = new();
private int _borrowed = 0;
public int ActiveDownloads{
get{
lock (downloadStartLock){
return activeOrStarting.Count;
}
}
}
private readonly object downloadStartLock = new();
private readonly HashSet<CrunchyEpMeta> activeOrStarting = new();
private readonly object processingLock = new();
private readonly SemaphoreSlim activeProcessingJobs;
private int processingJobsLimit;
private int borrowed;
private int pumpScheduled;
private int pumpDirty;
#endregion #endregion
[ObservableProperty] [ObservableProperty]
private bool _hasFailedItem; private bool hasFailedItem;
#region Singelton public event EventHandler? QueueStateChanged;
private static QueueManager? _instance; private readonly CrunchyrollManager crunchyrollManager;
private static readonly object Padlock = new();
public static QueueManager Instance{ public QueueManager(){
get{ this.crunchyrollManager = CrunchyrollManager.Instance;
if (_instance == null){
lock (Padlock){
if (_instance == null){
_instance = new QueueManager();
}
}
}
return _instance; activeProcessingJobs = new SemaphoreSlim(
initialCount: crunchyrollManager.CrunOptions.SimultaneousProcessingJobs,
maxCount: 2);
processingJobsLimit = crunchyrollManager.CrunOptions.SimultaneousProcessingJobs;
Queue.CollectionChanged += UpdateItemListOnRemove;
Queue.CollectionChanged += (_, _) => OnQueueStateChanged();
}
public bool TryStartDownload(DownloadItemModel model){
var item = model.epMeta;
lock (downloadStartLock){
if (activeOrStarting.Contains(item))
return false;
if (item.DownloadProgress is{ IsDownloading: true })
return false;
if (item.DownloadProgress is{ Done: true })
return false;
if (item.DownloadProgress is{ Error: true })
return false;
if (activeOrStarting.Count >= crunchyrollManager.CrunOptions.SimultaneousDownloads)
return false;
activeOrStarting.Add(item);
}
OnQueueStateChanged();
_ = model.StartDownloadCore();
return true;
}
public void ReleaseDownloadSlot(CrunchyEpMeta item){
bool removed;
lock (downloadStartLock){
removed = activeOrStarting.Remove(item);
}
if (removed){
OnQueueStateChanged();
RequestPump();
} }
} }
#endregion public Task WaitForProcessingSlotAsync(CancellationToken cancellationToken = default){
return activeProcessingJobs.WaitAsync(cancellationToken);
public QueueManager(){
Queue.CollectionChanged += UpdateItemListOnRemove;
} }
public void IncrementDownloads(){ public void ReleaseProcessingSlot(){
Interlocked.Increment(ref activeDownloads); lock (processingLock){
} if (borrowed > 0){
borrowed--;
public void ResetDownloads(){
Interlocked.Exchange(ref activeDownloads, 0);
}
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; return;
}
activeProcessingJobs.Release();
}
}
public void SetLimit(int newLimit){
if (newLimit < 0)
throw new ArgumentOutOfRangeException(nameof(newLimit));
lock (processingLock){
if (newLimit == processingJobsLimit)
return;
int delta = newLimit - processingJobsLimit;
if (delta > 0){
int giveBack = Math.Min(borrowed, delta);
borrowed -= giveBack;
int toRelease = delta - giveBack;
if (toRelease > 0)
activeProcessingJobs.Release(toRelease);
} else{
int toRemove = -delta;
while (toRemove > 0 && activeProcessingJobs.Wait(0)){
toRemove--;
}
borrowed += toRemove;
}
processingJobsLimit = newLimit;
} }
} }
@ -96,10 +173,21 @@ public partial class QueueManager : ObservableObject{
UpdateDownloadListItems(); UpdateDownloadListItems();
} }
public void UpdateDownloadListItems(){ public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
var list = Queue; Avalonia.Threading.Dispatcher.UIThread.Post(() => {
if (removeFromQueue){
if (Queue.Contains(item))
Queue.Remove(item);
} else{
Queue.Refresh();
}
foreach (CrunchyEpMeta crunchyEpMeta in list){ OnQueueStateChanged();
}, Avalonia.Threading.DispatcherPriority.Background);
}
public void UpdateDownloadListItems(){
foreach (CrunchyEpMeta crunchyEpMeta in Queue.ToList()){
var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta)); var downloadItem = DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
if (downloadItem != null){ if (downloadItem != null){
downloadItem.Refresh(); downloadItem.Refresh();
@ -108,13 +196,90 @@ public partial class QueueManager : ObservableObject{
_ = downloadItem.LoadImage(); _ = downloadItem.LoadImage();
DownloadItemModels.Add(downloadItem); DownloadItemModels.Add(downloadItem);
} }
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); HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
}
public void RequestPump(){
Interlocked.Exchange(ref pumpDirty, 1);
if (Interlocked.CompareExchange(ref pumpScheduled, 1, 0) != 0)
return;
Avalonia.Threading.Dispatcher.UIThread.Post(
RunPump,
Avalonia.Threading.DispatcherPriority.Background);
}
private void RunPump(){
try{
while (Interlocked.Exchange(ref pumpDirty, 0) == 1){
PumpQueue();
}
} finally{
Interlocked.Exchange(ref pumpScheduled, 0);
if (Volatile.Read(ref pumpDirty) == 1 &&
Interlocked.CompareExchange(ref pumpScheduled, 1, 0) == 0){
Avalonia.Threading.Dispatcher.UIThread.Post(
RunPump,
Avalonia.Threading.DispatcherPriority.Background);
}
}
}
private void PumpQueue(){
List<CrunchyEpMeta> toStart = new();
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.Error)
continue;
if (item.DownloadProgress.Done)
continue;
if (item.DownloadProgress.IsDownloading)
continue;
if (activeOrStarting.Contains(item))
continue;
activeOrStarting.Add(item);
freeSlots--;
toStart.Add(item);
}
}
foreach (var item in toStart){
var model = DownloadItemModels.FirstOrDefault(x => x.epMeta.Equals(item));
if (model != null){
_ = model.StartDownloadCore();
} else{
ReleaseDownloadSlot(item);
}
}
OnQueueStateChanged();
}
private void OnQueueStateChanged(){
QueueStateChanged?.Invoke(this, EventArgs.Empty);
} }
@ -134,13 +299,13 @@ public partial class QueueManager : ObservableObject{
return; return;
} }
var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData((CrunchyEpisode)episodeL, updateHistory); var sList = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episodeL, updateHistory);
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", ""); (HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){ if (CrunchyrollManager.Instance.CrunOptions.History){
var episode = sList.EpisodeAndLanguages.Items.First(); var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(episode.SeriesId, episode.SeasonId, episode.Id); historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
if (historyEpisode.dublist.Count > 0){ if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist; dubLang = historyEpisode.dublist;
} }
@ -194,23 +359,29 @@ public partial class QueueManager : ObservableObject{
if (sortedMetaData.Count != 0){ if (sortedMetaData.Count != 0){
var first = sortedMetaData.First(); var first = sortedMetaData.First();
selected.Data =[first]; selected.Data = [first];
selected.SelectedDubs =[first.Lang?.CrLocale ?? string.Empty]; selected.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
} }
} }
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions); 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){ switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo: case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false; newOptions.Novids = false;
newOptions.Noaudio = true; newOptions.Noaudio = true;
selected.DownloadSubs =["none"]; selected.DownloadSubs = ["none"];
break; break;
case EpisodeDownloadMode.OnlyAudio: case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true; newOptions.Novids = true;
newOptions.Noaudio = false; newOptions.Noaudio = false;
selected.DownloadSubs =["none"]; selected.DownloadSubs = ["none"];
break; break;
case EpisodeDownloadMode.OnlySubs: case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true; newOptions.Novids = true;
@ -221,12 +392,26 @@ public partial class QueueManager : ObservableObject{
break; break;
} }
if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ??[]).Contains(item))){ if (!selected.DownloadSubs.Contains("none") && selected.DownloadSubs.All(item => (selected.AvailableSubs ?? []).Contains(item))){
if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){ if (!(selected.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
selected.HighlightAllAvailable = true; 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; newOptions.DubLang = dubLang;
selected.DownloadSettings = newOptions; selected.DownloadSettings = newOptions;
@ -238,11 +423,12 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs"); 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: "); 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) => var languages = sList.EpisodeAndLanguages.Variants
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray(); .Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang.CrLocale}")
.ToArray();
Console.Error.WriteLine( Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]"); $"{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)); MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
} else{ } else{
Console.WriteLine("Added Episode to Queue"); Console.WriteLine("Added Episode to Queue");
@ -252,11 +438,14 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Episode couldn't be added to Queue"); Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: "); Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) => var languages = sList.EpisodeAndLanguages.Variants
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray(); .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 ??[])}]"); 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)); 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; return;
@ -275,16 +464,22 @@ public partial class QueueManager : ObservableObject{
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions); 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){ switch (episodeDownloadMode){
case EpisodeDownloadMode.OnlyVideo: case EpisodeDownloadMode.OnlyVideo:
newOptions.Novids = false; newOptions.Novids = false;
newOptions.Noaudio = true; newOptions.Noaudio = true;
movieMeta.DownloadSubs =["none"]; movieMeta.DownloadSubs = ["none"];
break; break;
case EpisodeDownloadMode.OnlyAudio: case EpisodeDownloadMode.OnlyAudio:
newOptions.Novids = true; newOptions.Novids = true;
newOptions.Noaudio = false; newOptions.Noaudio = false;
movieMeta.DownloadSubs =["none"]; movieMeta.DownloadSubs = ["none"];
break; break;
case EpisodeDownloadMode.OnlySubs: case EpisodeDownloadMode.OnlySubs:
newOptions.Novids = true; newOptions.Novids = true;
@ -301,6 +496,20 @@ public partial class QueueManager : ObservableObject{
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo; movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!movieMeta.DownloadSubs.Contains("none") && !movieMeta.DownloadSubs.Contains("all") && !movieMeta.DownloadSubs.All(item => (movieMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
if (movieMeta.Data.Count < dubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {movieMeta.SeasonTitle} - Season {movieMeta.Season} - {movieMeta.EpisodeTitle}");
return;
}
}
Queue.Add(movieMeta); Queue.Add(movieMeta);
Console.WriteLine("Added Movie to Queue"); Console.WriteLine("Added Movie to Queue");
@ -374,14 +583,14 @@ public partial class QueueManager : ObservableObject{
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){ 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 selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var failed = false; var failed = false;
var partialAdd = false; var partialAdd = false;
foreach (var crunchyEpMeta in selected.Values.ToList()){ foreach (var crunchyEpMeta in selected.Values.ToList()){
if (crunchyEpMeta.Data?.First() != null){ if (crunchyEpMeta.Data.FirstOrDefault() != null){
if (CrunchyrollManager.Instance.CrunOptions.History){ if (CrunchyrollManager.Instance.CrunOptions.History){
var historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDownloadDir(crunchyEpMeta.SeriesId, crunchyEpMeta.SeasonId, crunchyEpMeta.Data.First().MediaId); 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 (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true, UseSonarrNumbering: true }){
@ -426,13 +635,19 @@ public partial class QueueManager : ObservableObject{
if (sortedMetaData.Count != 0){ if (sortedMetaData.Count != 0){
var first = sortedMetaData.First(); var first = sortedMetaData.First();
crunchyEpMeta.Data =[first]; crunchyEpMeta.Data = [first];
crunchyEpMeta.SelectedDubs =[first.Lang?.CrLocale ?? string.Empty]; crunchyEpMeta.SelectedDubs = [first.Lang?.CrLocale ?? string.Empty];
} }
} }
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions); 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){ if (crunchyEpMeta.OnlySubs){
newOptions.Novids = true; newOptions.Novids = true;
newOptions.Noaudio = true; newOptions.Noaudio = true;
@ -442,12 +657,25 @@ public partial class QueueManager : ObservableObject{
crunchyEpMeta.DownloadSettings = newOptions; crunchyEpMeta.DownloadSettings = newOptions;
if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ??[]).Contains(item))){ if (!crunchyEpMeta.DownloadSubs.Contains("none") && crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){ if (!(crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub)){
crunchyEpMeta.HighlightAllAvailable = true; crunchyEpMeta.HighlightAllAvailable = true;
} }
} }
if (newOptions.DownloadOnlyWithAllSelectedDubSub){
if (!crunchyEpMeta.DownloadSubs.Contains("none") && !crunchyEpMeta.DownloadSubs.Contains("all") && !crunchyEpMeta.DownloadSubs.All(item => (crunchyEpMeta.AvailableSubs ?? []).Contains(item))){
//missing subs
Console.Error.WriteLine($"Episode not added because of missing subs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
if (crunchyEpMeta.Data.Count < data.DubLang.Count && !CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub){
//missing dubs
Console.Error.WriteLine($"Episode not added because of missing dubs - {crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle}");
continue;
}
}
Queue.Add(crunchyEpMeta); Queue.Add(crunchyEpMeta);
@ -457,10 +685,10 @@ public partial class QueueManager : ObservableObject{
partialAdd = true; partialAdd = true;
var languages = (crunchyEpMeta.Data.First().Versions ??[]).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray(); var languages = (crunchyEpMeta.Data.First().Versions ?? []).Select(version => $"{(version.IsPremiumOnly ? "+ " : "")}{version.AudioLocale}").ToArray();
Console.Error.WriteLine( Console.Error.WriteLine(
$"{crunchyEpMeta.SeasonTitle} - Season {crunchyEpMeta.Season} - {crunchyEpMeta.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", crunchyEpMeta.AvailableSubs ??[])}]"); $"{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)); MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue but couldn't find all selected dubs", ToastType.Warning, 2));
} }
} else{ } else{
@ -476,32 +704,4 @@ public partial class QueueManager : ObservableObject{
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2)); 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);
});
}
}
_limit = newLimit;
}
}
} }

View file

@ -6,9 +6,6 @@ using ReactiveUI.Avalonia;
namespace CRD; namespace CRD;
sealed class Program{ 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] [STAThread]
public static void Main(string[] args){ public static void Main(string[] args){
var isHeadless = args.Contains("--headless"); var isHeadless = args.Contains("--headless");
@ -16,19 +13,12 @@ sealed class Program{
BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args); 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){ public static AppBuilder BuildAvaloniaApp(bool isHeadless){
var builder = AppBuilder.Configure<App>() var builder = AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont() .WithInterFont()
.LogToTrace() .LogToTrace()
.UseReactiveUI() ; .UseReactiveUI(_ => { });
if (isHeadless){ if (isHeadless){
Console.WriteLine("Running in headless mode..."); Console.WriteLine("Running in headless mode...");

View file

@ -60,13 +60,28 @@
</ContentPresenter.Styles> </ContentPresenter.Styles>
</ContentPresenter> </ContentPresenter>
<Viewbox Name="IconBox" <Grid Width="28"
Height="28" Height="28"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center"> VerticalAlignment="Center">
<ContentPresenter Name="Icon"
Content="{Binding TemplateSettings.Icon, RelativeSource={RelativeSource TemplatedParent}}" /> <Viewbox Name="IconBox"
</Viewbox> 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> </DockPanel>
</Panel> </Panel>
@ -74,6 +89,9 @@
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
</Style> </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"> <Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pointerover /template/ ContentPresenter#ContentPresenter">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" /> <Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
</Style> </Style>

View file

@ -5,6 +5,7 @@ using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
namespace CRD.Utils.DRM; namespace CRD.Utils.DRM;

View file

@ -276,10 +276,19 @@ public enum EpisodeDownloadMode{
OnlySubs, OnlySubs,
} }
public enum HistoryRefreshMode{
DefaultAll = 0,
DefaultActive = 1,
FastNewReleases = 50
}
public enum SonarrCoverType{ public enum SonarrCoverType{
Unknown,
Poster,
Banner, Banner,
FanArt, FanArt,
Poster, Screenshot,
Headshot,
ClearLogo, ClearLogo,
} }

View file

@ -2,12 +2,14 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Downloader; using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils.Http;
using CRD.Utils.Parser.Utils; using CRD.Utils.Parser.Utils;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -481,6 +483,8 @@ public class HlsDownloader{
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "") Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
}; };
QueueManager.Instance.Queue.Refresh();
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){ if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
if (!_currentEpMeta.DownloadProgress.Done){ if (!_currentEpMeta.DownloadProgress.Done){
CleanupNewDownloadMethod(tempDir, resumeFile, true); CleanupNewDownloadMethod(tempDir, resumeFile, true);
@ -568,7 +572,7 @@ public class HlsDownloader{
byte[]? part; byte[]? part;
if (seg.Key != null){ if (seg.Key != null){
var decipher = await GetKey(seg.Key, p, segOffset); var decipher = await GetKey(seg.Key, p, segOffset);
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries); part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
var partContent = part; var partContent = part;
using (decipher){ using (decipher){
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length); if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
@ -579,7 +583,7 @@ public class HlsDownloader{
Interlocked.Add(ref _data.TotalBytes, dec.Length); Interlocked.Add(ref _data.TotalBytes, dec.Length);
} }
} else{ } else{
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries); part = await GetData(p, sUri, seg.ByteRange, segOffset, false, _data.Timeout, _data.Retries);
dec = part; dec = part;
if (dec != null){ if (dec != null){
Interlocked.Add(ref _data.BytesDownloaded, dec.Length); Interlocked.Add(ref _data.BytesDownloaded, dec.Length);
@ -642,7 +646,7 @@ public class HlsDownloader{
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl); string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
if (!_data.Keys.ContainsKey(kUri)){ if (!_data.Keys.ContainsKey(kUri)){
try{ try{
var rkey = await GetData(segIndex, kUri, new Dictionary<string, string>(), segOffset, true, _data.Timeout, _data.Retries); var rkey = await GetData(segIndex, kUri, null, segOffset, true, _data.Timeout, _data.Retries);
if (rkey == null || rkey.Length != 16){ if (rkey == null || rkey.Length != 16){
throw new Exception("Key not fully downloaded or is incorrect."); throw new Exception("Key not fully downloaded or is incorrect.");
} }
@ -658,7 +662,7 @@ public class HlsDownloader{
return _data.Keys[kUri]; return _data.Keys[kUri];
} }
public async Task<byte[]?> GetData(int partIndex, string uri, IDictionary<string, string> headers, int segOffset, bool isKey, int timeout, int retryCount){ public async Task<byte[]?> GetData(int partIndex, string uri, ByteRange? byteRange, int segOffset, bool isKey, int timeout, int retryCount){
// Handle local file URI // Handle local file URI
if (uri.StartsWith("file://")){ if (uri.StartsWith("file://")){
string path = new Uri(uri).LocalPath; string path = new Uri(uri).LocalPath;
@ -667,8 +671,8 @@ public class HlsDownloader{
// Setup request headers // Setup request headers
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
foreach (var header in headers){ if (byteRange != null){
request.Headers.TryAddWithoutValidation(header.Key, header.Value); request.Headers.Range = new RangeHeaderValue(byteRange.Offset, byteRange.Offset + byteRange.Length - 1);
} }
// Set default user-agent if not provided // Set default user-agent if not provided
@ -797,13 +801,6 @@ public class Key{
public class ByteRange{ public class ByteRange{
public long Offset{ get; set; } public long Offset{ get; set; }
public long Length{ 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{ public class HlsOptions{

View file

@ -7,6 +7,7 @@ using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -19,7 +20,9 @@ using CRD.Downloader;
using CRD.Utils.Ffmpeg_Encoding; using CRD.Utils.Ffmpeg_Encoding;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.HLS; using CRD.Utils.HLS;
using CRD.Utils.Http;
using CRD.Utils.JsonConv; using CRD.Utils.JsonConv;
using CRD.Utils.Parser;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Microsoft.Win32; using Microsoft.Win32;
@ -59,7 +62,7 @@ public class Helpers{
return clone; return clone;
} }
public static T DeepCopy<T>(T obj){ public static T? DeepCopy<T>(T obj){
var settings = new JsonSerializerSettings{ var settings = new JsonSerializerSettings{
ContractResolver = new DefaultContractResolver{ ContractResolver = new DefaultContractResolver{
IgnoreSerializableAttribute = true, IgnoreSerializableAttribute = true,
@ -190,80 +193,7 @@ public class Helpers{
} }
} }
public static Locale ConvertStringToLocale(string? value){ public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string bin, string command){
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{ try{
using (var process = new Process()){ using (var process = new Process()){
process.StartInfo.FileName = bin; process.StartInfo.FileName = bin;
@ -360,158 +290,201 @@ public class Helpers{
} }
} }
private static string GetQualityOption(VideoPreset preset){ private static IEnumerable<string> GetQualityOption(VideoPreset preset){
return preset.Codec switch{ return preset.Codec switch{
"h264_nvenc" or "hevc_nvenc" => $"-cq {preset.Crf}", // For NVENC "h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
"h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV "h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
"h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE "h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
_ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265 _ =>["-crf", preset.Crf.ToString()]
}; };
} }
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{ try{
string outputExtension = Path.GetExtension(inputFilePath); string ext = Path.GetExtension(inputFilePath);
string directory = Path.GetDirectoryName(inputFilePath); string dir = Path.GetDirectoryName(inputFilePath)!;
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath); string name = Path.GetFileNameWithoutExtension(inputFilePath);
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => { string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
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); TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
if (totalDuration == null){
Console.Error.WriteLine("Unable to retrieve input file duration."); var args = new List<string>{
} else{ "-nostdin",
Console.WriteLine($"Total Duration: {totalDuration}"); "-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
};
if (!string.IsNullOrWhiteSpace(preset.Codec)){
args.Add("-c:v");
args.Add(preset.Codec);
} }
args.AddRange(GetQualityOption(preset));
string ffmpegCommand = $"-loglevel info -i \"{inputFilePath}\" -c:v {preset.Codec} {qualityOption} -vf \"scale={preset.Resolution},fps={preset.FrameRate}\" {additionalParams} \"{tempOutputFilePath}\""; args.Add("-vf");
using (var process = new Process()){ args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
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) => { foreach (var param in preset.AdditionalParameters){
if (!string.IsNullOrEmpty(e.Data)){ args.AddRange(SplitArguments(param));
Console.WriteLine(e.Data); }
}
};
process.ErrorDataReceived += (sender, e) => { args.Add(tempOutput);
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine($"{e.Data}");
if (data != null && totalDuration != null){
ParseProgress(e.Data, totalDuration.Value, data);
}
}
};
process.Start(); string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
process.BeginOutputReadLine(); int exitCode;
process.BeginErrorReadLine(); try{
exitCode = await RunFFmpegAsync(
using var reg = data?.Cts.Token.Register(() => { CfgManager.PathFFMPEG,
args,
data?.Cts.Token ?? CancellationToken.None,
onStdErr: line => { Console.Error.WriteLine(line); },
onStdOut: Console.WriteLine
);
} catch (OperationCanceledException){
if (File.Exists(tempOutput)){
try{ try{
if (!process.HasExited) File.Delete(tempOutput);
process.Kill(true);
} catch{ } catch{
// ignored // ignored
} }
});
try{
await process.WaitForExitAsync(data.Cts.Token);
} catch (OperationCanceledException){
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
// ignored
}
}
return (IsOk: false, ErrorCode: -2);
} }
bool isSuccess = process.ExitCode == 0; Console.Error.WriteLine("FFMPEG task was canceled");
return (false, -2);
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);
} }
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){ } catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}"); Console.Error.WriteLine(ex);
return (IsOk: false, ErrorCode: -1);
return (false, -1);
} }
} }
private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){ private static IEnumerable<string> SplitArguments(string commandLine){
try{ var args = new List<string>();
if (progressString.Contains("time=")){ var current = new StringBuilder();
var timeIndex = progressString.IndexOf("time=") + 5; bool inQuotes = false;
var timeString = progressString.Substring(timeIndex, 11);
foreach (char c in commandLine){
if (TimeSpan.TryParse(timeString, out var currentTime)){ if (c == '"'){
int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100); inQuotes = !inQuotes;
Console.WriteLine($"Progress: {progress:F2}%"); continue;
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true, if (char.IsWhiteSpace(c) && !inQuotes){
Percent = progress, if (current.Length > 0){
Time = 0, args.Add(current.ToString());
DownloadSpeedBytes = 0, current.Clear();
Doing = "Encoding" }
}; } else{
current.Append(c);
QueueManager.Instance.Queue.Refresh();
}
} }
} catch (Exception e){
Console.Error.WriteLine("Failed to calculate encoding progess");
Console.Error.WriteLine(e.Message);
} }
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();
// 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){
try{
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
}
throw;
}
await Task.WhenAll(stdoutTask, stderrTask);
return process.ExitCode;
}
public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){ public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){
try{ try{
using (var process = new Process()){ using (var process = new Process()){
@ -911,6 +884,8 @@ public class Helpers{
if (result == ContentDialogResult.Primary){ if (result == ContentDialogResult.Primary){
timer.Stop(); timer.Stop();
} }
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{ } finally{
ShutdownLock.Release(); ShutdownLock.Release();
} }
@ -967,4 +942,22 @@ public class Helpers{
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}"); 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,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
@ -7,35 +8,52 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace CRD.Utils; namespace CRD.Utils.Http;
public class FlareSolverrClient{ public class FlareSolverrClient{
private readonly HttpClient _httpClient; private readonly HttpClient httpClient;
private FlareSolverrProperties properties; private FlareSolverrProperties? flareProperties;
private readonly MitmProxyProperties? mitmProperties;
private string flaresolverrUrl = "http://localhost:8191"; private string flaresolverrUrl = "http://localhost:8191";
private readonly string mitmProxyUrl = "localhost:8080";
private const string HeaderToken = "$$headers[]";
private const string PostToken = "$$post";
public FlareSolverrClient(){ public FlareSolverrClient(){
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null) properties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties; flareProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
mitmProperties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrMitmProperties;
if (properties != null){ if (flareProperties != null){
flaresolverrUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}"; flaresolverrUrl = $"http{(flareProperties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(flareProperties.Host) ? flareProperties.Host : "localhost")}:{flareProperties.Port}";
} }
_httpClient = new HttpClient{ BaseAddress = new Uri(flaresolverrUrl) }; if (mitmProperties != null){
_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"); mitmProxyUrl =
$"{(!string.IsNullOrWhiteSpace(mitmProperties.Host) ? mitmProperties.Host : "localhost")}:" +
$"{mitmProperties.Port}";
}
httpClient = new HttpClient{ BaseAddress = new Uri(flaresolverrUrl) };
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
} }
public async Task<(bool IsOk, string ResponseContent, List<Cookie> cookies)> SendViaFlareSolverrAsync(HttpRequestMessage request,List<Cookie> cookiesToSend){ public Task<(bool IsOk, string ResponseContent, List<Cookie> Cookies, string UserAgent)> SendViaProxySolverAsync(
HttpRequestMessage request,
List<Cookie> cookiesToSend){
return mitmProperties is{ UseMitmProxy: true }
? SendViaMitmProxyAsync(request, cookiesToSend)
: SendViaFlareSolverrAsync(request, cookiesToSend);
}
public async Task<(bool IsOk, string ResponseContent, List<Cookie> cookies, string UserAgent)> SendViaFlareSolverrAsync(HttpRequestMessage request, List<Cookie> cookiesToSend){
var flaresolverrCookies = new List<object>(); var flaresolverrCookies = new List<object>();
foreach (var cookie in cookiesToSend) foreach (var cookie in cookiesToSend){
{ flaresolverrCookies.Add(new{
flaresolverrCookies.Add(new
{
name = cookie.Name, name = cookie.Name,
value = cookie.Value, value = cookie.Value,
domain = cookie.Domain, domain = cookie.Domain,
@ -71,10 +89,10 @@ public class FlareSolverrClient{
HttpResponseMessage flareSolverrResponse; HttpResponseMessage flareSolverrResponse;
try{ try{
flareSolverrResponse = await _httpClient.SendAsync(flareSolverrRequest); flareSolverrResponse = await httpClient.SendAsync(flareSolverrRequest);
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"Error sending request to FlareSolverr: {ex.Message}"); Console.Error.WriteLine($"Error sending request to FlareSolverr: {ex.Message}");
return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", []); return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", [], string.Empty);
} }
string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync(); string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync();
@ -83,10 +101,10 @@ public class FlareSolverrClient{
var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent); var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent);
if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){ if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){
return (IsOk: true, ResponseContent: flareSolverrResult.Solution.Response, flareSolverrResult.Solution.cookies); return (IsOk: true, ResponseContent: flareSolverrResult.Solution?.Response ?? string.Empty, flareSolverrResult.Solution?.Cookies ?? [], flareSolverrResult.Solution?.UserAgent ?? string.Empty);
} else{ } else{
Console.Error.WriteLine($"Flare Solverr Failed \n Response: {flareSolverrResponseContent}"); Console.Error.WriteLine($"Flare Solverr Failed \n Response: {flareSolverrResponseContent}");
return (IsOk: false, ResponseContent: flareSolverrResponseContent, []); return (IsOk: false, ResponseContent: flareSolverrResponseContent, [], string.Empty);
} }
} }
@ -115,23 +133,179 @@ public class FlareSolverrClient{
return cookiesDictionary; 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 class FlareSolverrResponse{
public string Status{ get; set; } public string? Status{ get; set; }
public FlareSolverrSolution Solution{ get; set; } public FlareSolverrSolution? Solution{ get; set; }
} }
public class FlareSolverrSolution{ public class FlareSolverrSolution{
public string Url{ get; set; } [JsonProperty("url")]
public string Status{ get; set; } public string? Url{ get; set; }
public List<Cookie> cookies{ get; set; }
public string Response{ get; set; } [JsonProperty("status")]
public string? Status{ get; set; }
[JsonProperty("cookies")]
public List<Cookie> Cookies{ get; set; } = [];
[JsonProperty("response")]
public string? Response{ get; set; } = string.Empty;
[JsonProperty("userAgent")]
public string UserAgent{ get; set; } = string.Empty;
} }
public class FlareSolverrProperties(){ public class FlareSolverrProperties(){
public bool UseFlareSolverr{ get; set; } public bool UseFlareSolverr{ get; set; }
public string? Host{ get; set; } public string? Host{ get; set; } = "localhost";
public int Port{ get; set; } public int Port{ get; set; }
public bool UseSsl{ 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,48 +1,30 @@
using System; using System;
using System.Net;
using System.Net.Http;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
namespace CRD.Utils; namespace CRD.Utils.Http;
public class HttpClientReq{ public class HttpClientReq{
#region Singelton public static HttpClientReq Instance{ get; } = new();
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; private HttpClient client;
public readonly bool useFlareSolverr; public readonly bool UseFlareSolverr;
private FlareSolverrClient flareSolverrClient; private FlareSolverrClient? flareSolverrClient;
public HttpClientReq(){ public HttpClientReq(){
IWebProxy systemProxy = WebRequest.DefaultWebProxy; IWebProxy? systemProxy = WebRequest.DefaultWebProxy;
HttpClientHandler handler = new HttpClientHandler(); HttpClientHandler handler;
if (CrunchyrollManager.Instance.CrunOptions.ProxyEnabled && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.ProxyHost)){ 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, handler = CreateHandler(true, CrunchyrollManager.Instance.CrunOptions.ProxySocks, CrunchyrollManager.Instance.CrunOptions.ProxyHost, CrunchyrollManager.Instance.CrunOptions.ProxyPort,
@ -74,7 +56,7 @@ public class HttpClientReq{
} }
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"); // client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0");
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"); client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
// client.DefaultRequestHeaders.UserAgent.ParseAdd("Crunchyroll/1.9.0 Nintendo Switch/18.1.0.0 UE4/4.27"); // client.DefaultRequestHeaders.UserAgent.ParseAdd("Crunchyroll/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"); // client.DefaultRequestHeaders.UserAgent.ParseAdd("Crunchyroll/3.60.0 Android/9 okhttp/4.12.0");
@ -84,7 +66,7 @@ public class HttpClientReq{
client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive"); client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive");
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null && CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties.UseFlareSolverr){ if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null && CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties.UseFlareSolverr){
useFlareSolverr = true; UseFlareSolverr = true;
flareSolverrClient = new FlareSolverrClient(); flareSolverrClient = new FlareSolverrClient();
} }
} }
@ -130,15 +112,37 @@ public class HttpClientReq{
return handler; return handler;
} }
public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null){ public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
bool allowChallengeBypass = true){
string content = string.Empty; string content = string.Empty;
try{ try{
AttachCookies(request, cookieStore); if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){
AttachCookies(request, cookieStore);
}
var retryRequest = await CloneHttpRequestMessageAsync(request);
HttpResponseMessage response = await client.SendAsync(request); HttpResponseMessage response = await client.SendAsync(request);
if (ChallengeDetector.IsClearanceRequired(response)){ if (allowChallengeBypass && ChallengeDetector.IsClearanceRequired(response)){
Console.Error.WriteLine($" Cloudflare Challenge detected"); 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(); content = await response.Content.ReadAsStringAsync();
@ -149,16 +153,16 @@ public class HttpClientReq{
return (IsOk: true, ResponseContent: content, error: ""); return (IsOk: true, ResponseContent: content, error: "");
} catch (Exception e){ } catch (Exception e){
// Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
if (!suppressError){ if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
} }
return (IsOk: false, ResponseContent: content, error: "");
return (IsOk: false, ResponseContent: content, error: e.Message);
} }
} }
public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){ 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; string content = string.Empty;
try{ try{
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []); var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
@ -166,7 +170,7 @@ public class HttpClientReq{
content = flareSolverrResponses.ResponseContent; content = flareSolverrResponses.ResponseContent;
return (IsOk: flareSolverrResponses.IsOk, ResponseContent: content, error: ""); return (flareSolverrResponses.IsOk, ResponseContent: content, error: "");
} catch (Exception e){ } catch (Exception e){
if (!suppressError){ if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
@ -235,7 +239,7 @@ public class HttpClientReq{
} }
if (cookieStore.TryGetValue(domain, out var cookies)){ if (cookieStore.TryGetValue(domain, out var cookies)){
var cookie = cookies.Cast<Cookie>().FirstOrDefault(c => c.Name == cookieName); var cookie = cookies.FirstOrDefault(c => c.Name == cookieName);
return cookie?.Value; return cookie?.Value;
} }
@ -260,6 +264,71 @@ public class HttpClientReq{
cookieStore[domain].Add(cookie); 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){ public static HttpRequestMessage CreateRequestMessage(string uri, HttpMethod requestMethod, bool authHeader, string? accessToken = "", NameValueCollection? query = null){
if (string.IsNullOrEmpty(uri)){ if (string.IsNullOrEmpty(uri)){
@ -296,7 +365,7 @@ public static class ApiUrls{
public static string Auth => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/auth/v1/token"; public static string 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 Profile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/profile";
public static string Profiles => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile"; public static string MultiProfile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string CmsToken => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/index/v2"; public static string 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 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"; public static string Browse => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/browse";

View file

@ -0,0 +1,32 @@
using System.Collections.Generic;
using CRD.Utils.Muxing.Structs;
namespace CRD.Utils.Muxing.Commands;
public abstract class CommandBuilder{
private protected readonly MergerOptions Options;
private protected readonly List<string> Args = new();
public CommandBuilder(MergerOptions options){
Options = options;
}
public abstract string Build();
private protected void Add(string arg){
Args.Add(arg);
}
private protected void AddIf(bool condition, string arg){
if (condition)
Add(arg);
}
private protected void AddInput(string path){
Add($"\"{Helpers.AddUncPrefixIfNeeded(path)}\"");
}
private protected void AddRange(IEnumerable<string> args){
Args.AddRange(args);
}
}

View file

@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Commands;
public class FFmpegCommandBuilder : CommandBuilder{
private readonly List<string> metaData = new();
private int index;
private int audioIndex;
private bool hasVideo;
public FFmpegCommandBuilder(MergerOptions options) : base(options){
}
public override string Build(){
AddLogLevel();
if (!Options.mp3)
BuildMux();
else
BuildMp3();
return string.Join(" ", Args);
}
private void AddLogLevel(){
Add("-loglevel warning");
}
private void BuildMux(){
AddVideoInputs();
AddAudioInputs();
AddChapterInput();
AddSubtitleInputs();
AddRange(metaData);
AddCodecs();
AddSubtitleMetadata();
AddGlobalMetadata();
AddCustomOptions();
AddOutput();
}
private void AddVideoInputs(){
foreach (var vid in Options.OnlyVid){
if (!hasVideo || Options.KeepAllVideos){
Add($"-i \"{vid.Path}\"");
metaData.Add($"-map {index}:v");
metaData.Add($"-metadata:s:v:{index} title=\"{vid.Language.Name}\"");
hasVideo = true;
index++;
}
}
}
private void AddAudioInputs(){
foreach (var aud in Options.OnlyAudio){
if (aud.Delay is{ } delay && delay != 0){
double offset = delay / 1000.0;
Add($"-itsoffset {offset.ToString(CultureInfo.InvariantCulture)}");
}
Add($"-i \"{aud.Path}\"");
metaData.Add($"-map {index}:a");
metaData.Add($"-metadata:s:a:{audioIndex} language={aud.Language.Code}");
AddAudioDisposition(aud);
index++;
audioIndex++;
}
}
private void AddAudioDisposition(MergerInput aud){
if (Options.Defaults.Audio?.Code == aud.Language.Code &&
Options.Defaults.Audio != Languages.DEFAULT_lang){
metaData.Add($"-disposition:a:{audioIndex} default");
} else{
metaData.Add($"-disposition:a:{audioIndex} 0");
}
}
private void AddChapterInput(){
if (Options.Chapters is{ Count: > 0 }){
ConvertChapterFileForFFMPEG(Options.Chapters[0].Path);
Add($"-i \"{Options.Chapters[0].Path}\"");
metaData.Add($"-map_metadata {index}");
index++;
}
}
private void AddSubtitleInputs(){
if (Options.SkipSubMux)
return;
bool hasSignsSub =
Options.Subtitles.Any(s =>
s.Signs && Options.Defaults.Sub?.Code == s.Language.Code);
foreach (var sub in Options.Subtitles.Select((value, i) => new{ value, i })){
AddSubtitle(sub.value, sub.i, hasSignsSub);
}
}
private void AddSubtitle(SubtitleInput sub, int subIndex, bool hasSignsSub){
if (sub.Delay is{ } delay && delay != 0){
double offset = delay / 1000.0;
Add($"-itsoffset {offset.ToString(CultureInfo.InvariantCulture)}");
}
Add($"-i \"{sub.File}\"");
metaData.Add($"-map {index}:s");
if (Options.Defaults.Sub?.Code == sub.Language.Code &&
(Options.DefaultSubSigns == sub.Signs || Options.DefaultSubSigns && !hasSignsSub) &&
!sub.ClosedCaption){
metaData.Add($"-disposition:s:{subIndex} default");
} else{
metaData.Add($"-disposition:s:{subIndex} 0");
}
index++;
}
private void AddCodecs(){
Add("-c:v copy");
Add("-c:a copy");
Add(
Options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)
? "-c:s mov_text"
: "-c:s ass"
);
}
private void AddSubtitleMetadata(){
if (Options.SkipSubMux)
return;
AddRange(
Options.Subtitles.Select((sub, subIndex) =>
$"-metadata:s:s:{subIndex} title=\"{sub.Language.Language ?? sub.Language.Name}" +
$"{(sub.ClosedCaption ? $" {Options.CcTag}" : "")}" +
$"{(sub.Signs ? " Signs" : "")}\" " +
$"-metadata:s:s:{subIndex} language={sub.Language.Code}"
)
);
}
private void AddGlobalMetadata(){
if (!string.IsNullOrEmpty(Options.VideoTitle))
Add($"-metadata title=\"{Options.VideoTitle}\"");
if (Options.Description is{ Count: > 0 }){
XmlDocument doc = new();
doc.Load(Options.Description[0].Path);
XmlNode? node =
doc.SelectSingleNode("//Tag/Simple[Name='DESCRIPTION']/String");
string description =
node?.InnerText
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
?? string.Empty;
Add($"-metadata comment=\"{description}\"");
}
}
private void AddCustomOptions(){
if (Options.Options.Ffmpeg?.Count > 0)
AddRange(Options.Options.Ffmpeg);
}
private void AddOutput(){
Add($"\"{Options.Output}\"");
}
private void BuildMp3(){
if (Options.OnlyAudio.Count > 1)
Console.Error.WriteLine(
"Multiple audio files detected. Only one audio file can be converted to MP3 at a time."
);
var audio = Options.OnlyAudio.First();
Add($"-i \"{audio.Path}\"");
Add("-c:a libmp3lame" +
(audio.Bitrate > 0 ? $" -b:a {audio.Bitrate}k" : ""));
Add($"\"{Options.Output}\"");
}
private void ConvertChapterFileForFFMPEG(string chapterFilePath){
var chapterLines = File.ReadAllLines(chapterFilePath);
var ffmpegChapterLines = new List<string>{ ";FFMETADATA1" };
var chapters = new List<(double StartTime, string Title)>();
for (int i = 0; i < chapterLines.Length; i += 2){
var timeLine = chapterLines[i];
var nameLine = chapterLines[i + 1];
var timeParts = timeLine.Split('=');
var nameParts = nameLine.Split('=');
if (timeParts.Length == 2 && nameParts.Length == 2){
var startTime = TimeSpan.Parse(timeParts[1]).TotalMilliseconds;
var title = nameParts[1];
chapters.Add((startTime, title));
}
}
// Sort chapters by start time
chapters = chapters.OrderBy(c => c.StartTime).ToList();
for (int i = 0; i < chapters.Count; i++){
var startTime = chapters[i].StartTime;
var title = chapters[i].Title;
var endTime = (i + 1 < chapters.Count) ? chapters[i + 1].StartTime : startTime + 10000; // Add 10 seconds to the last chapter end time
if (endTime < startTime){
endTime = startTime + 10000; // Correct end time if it is before start time
}
ffmpegChapterLines.Add("[CHAPTER]");
ffmpegChapterLines.Add("TIMEBASE=1/1000");
ffmpegChapterLines.Add($"START={startTime}");
ffmpegChapterLines.Add($"END={endTime}");
ffmpegChapterLines.Add($"title={title}");
}
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
}
}

View file

@ -0,0 +1,244 @@
using System;
using System.Linq;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Commands;
public class MkvMergeCommandBuilder(MergerOptions options) : CommandBuilder(options){
private bool hasVideo;
public override string Build(){
AddOutput();
AddCustomOptions();
AddVideoAudio();
AddSubtitles();
AddFonts();
AddChapters();
AddTitle();
AddDescription();
AddCover();
return string.Join(" ", Args);
}
private void AddOutput(){
Add($"-o \"{Helpers.AddUncPrefixIfNeeded(Options.Output)}\"");
}
private void AddCustomOptions(){
if (Options.Options.Mkvmerge != null)
AddRange(Options.Options.Mkvmerge);
}
private void AddVideoAudio(){
if (Options.VideoAndAudio.Count > 0)
AddCombinedVideoAudio();
else{
AddVideoOnly();
AddAudioOnly();
}
}
private void AddCombinedVideoAudio(){
var rank = Options.DubLangList
.Select((v, i) => new{ v, i })
.ToDictionary(x => x.v, x => x.i, StringComparer.OrdinalIgnoreCase);
var sorted = Options.VideoAndAudio
.OrderBy(m => {
var key = m.Language?.CrLocale ?? string.Empty;
return rank.TryGetValue(key, out var r) ? r : int.MaxValue;
})
.ThenBy(m => m.IsAudioRoleDescription)
.ToList();
foreach (var track in sorted){
AddCombinedTrack(track);
}
}
private void AddCombinedTrack(MergerInput track){
var videoTrackNum = "0";
var audioTrackNum = "1";
if (!hasVideo || Options.KeepAllVideos){
Add($"--video-tracks {videoTrackNum}");
Add($"--audio-tracks {audioTrackNum}");
AddTrackMetadata(videoTrackNum, track.Language);
hasVideo = true;
} else{
Add("--no-video");
Add($"--audio-tracks {audioTrackNum}");
}
AddAudioMetadata(audioTrackNum, track);
AddInput(track.Path);
}
private void AddVideoOnly(){
foreach (var vid in Options.OnlyVid){
if (!hasVideo || Options.KeepAllVideos){
Add("--video-tracks 0");
Add("--no-audio");
AddTrackMetadata("0", vid.Language);
hasVideo = true;
AddInput(vid.Path);
}
}
}
private void AddAudioOnly(){
var sorted = Options.OnlyAudio
.OrderBy(a => {
var index = Options.DubLangList.IndexOf(a.Language.CrLocale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
foreach (var aud in sorted){
Add("--audio-tracks 0");
Add("--no-video");
AddAudioMetadata("0", aud);
if (aud.Delay is{ } delay && delay != 0)
Add($"--sync 0:{delay}");
AddInput(aud.Path);
}
}
private void AddTrackMetadata(string trackNum, LanguageItem lang){
Add($"--track-name {trackNum}:\"{lang.Name}\"");
Add($"--language {trackNum}:{lang.Code}");
}
private void AddAudioMetadata(string trackNum, MergerInput track){
var name = track.Language.Name +
(track.IsAudioRoleDescription ? " [AD]" : "");
Add($"--track-name {trackNum}:\"{name}\"");
Add($"--language {trackNum}:{track.Language.Code}");
AddDefaultAudio(trackNum, track.Language);
}
private void AddDefaultAudio(string trackNum, LanguageItem lang){
if (Options.Defaults.Audio?.Code == lang.Code &&
Options.Defaults.Audio != Languages.DEFAULT_lang){
Add($"--default-track {trackNum}");
} else{
Add($"--default-track {trackNum}:0");
}
}
private void AddSubtitles(){
if (Options.Subtitles.Count == 0 || Options.SkipSubMux){
Add("--no-subtitles");
return;
}
bool hasSignsSub =
Options.Subtitles.Any(s => s.Signs &&
Options.Defaults.Sub?.Code == s.Language.Code);
var sorted = Options.Subtitles
.OrderBy(s => Options.SubLangList.IndexOf(s.Language.CrLocale) != -1
? Options.SubLangList.IndexOf(s.Language.CrLocale)
: int.MaxValue)
.ThenBy(s => s.ClosedCaption ? 2 : s.Signs ? 1 : 0)
.ToList();
foreach (var sub in sorted)
AddSubtitle(sub, hasSignsSub);
}
private void AddSubtitle(SubtitleInput subObj, bool hasSignsSub){
bool isForced = false;
AddIf(subObj.Delay.HasValue, $"--sync 0:{subObj.Delay}");
string extra = subObj.ClosedCaption ? $" {Options.CcTag}" : "";
extra += subObj.Signs ? " Signs" : "";
string name = (subObj.Language.Language ?? subObj.Language.Name) + extra;
Add($"--track-name 0:\"{name}\"");
Add($"--language 0:\"{subObj.Language.Code}\"");
AddSubtitleDefaults(subObj, hasSignsSub, ref isForced);
if (subObj.ClosedCaption && Options.CcSubsMuxingFlag)
Add("--hearing-impaired-flag 0:yes");
if (subObj.Signs && Options.SignsSubsAsForced && !isForced)
Add("--forced-track 0:yes");
AddInput(subObj.File);
}
private void AddSubtitleDefaults(SubtitleInput subObj, bool hasSignsSub, ref bool isForced){
if (Options.Defaults.Sub != null && Options.Defaults.Sub != Languages.DEFAULT_lang){
if (Options.Defaults.Sub.Code == subObj.Language.Code &&
(Options.DefaultSubSigns == subObj.Signs || Options.DefaultSubSigns && !hasSignsSub) &&
subObj.ClosedCaption == false){
Add("--default-track 0");
if (Options.DefaultSubForcedDisplay){
Add("--forced-track 0:yes");
isForced = true;
}
} else{
Add("--default-track 0:0");
}
} else{
Add("--default-track 0:0");
}
}
private void AddFonts(){
if (Options.Fonts is not{ Count: > 0 }){
Add("--no-attachments");
return;
}
foreach (var font in Options.Fonts){
Add($"--attachment-name \"{font.Name}\"");
Add($"--attachment-mime-type \"{font.Mime}\"");
Add($"--attach-file \"{Helpers.AddUncPrefixIfNeeded(font.Path)}\"");
}
}
private void AddChapters(){
if (Options.Chapters is{ Count: > 0 })
Add($"--chapters \"{Helpers.AddUncPrefixIfNeeded(Options.Chapters[0].Path)}\"");
}
private void AddTitle(){
if (!string.IsNullOrEmpty(Options.VideoTitle))
Add($"--title \"{Options.VideoTitle}\"");
}
private void AddDescription(){
if (Options.Description is{ Count: > 0 })
Add($"--global-tags \"{Helpers.AddUncPrefixIfNeeded(Options.Description[0].Path)}\"");
}
private void AddCover(){
var cover = Options.Cover.FirstOrDefault();
if (cover?.Path != null){
Add($"--attach-file \"{cover.Path}\"");
Add("--attachment-mime-type image/png");
Add("--attachment-name cover.png");
}
}
}

View file

@ -0,0 +1,464 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
using CRD.Views;
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 string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
private readonly FontIndex index = new();
private void EnsureIndex(string fontsDir){
index.Rebuild(fontsDir);
}
public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts...");
var fonts = Fonts.Values.ToList();
foreach (var font in fonts){
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
continue;
}
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
File.Delete(fontLoc);
try{
if (!Directory.Exists(fontFolder))
Directory.CreateDirectory(fontFolder!);
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font;
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
}
Console.WriteLine("All required fonts downloaded!");
}
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)}");
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 = Path.Combine(fontsDir, crFile);
if (File.Exists(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 = Path.Combine(fontsDir, crFamilyFile);
if (File.Exists(p)){
resolvedPath = p;
return true;
}
}
}
return false;
}
private static string StripStyleSuffix(string name){
var n = name;
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$",
"", RegexOptions.IgnoreCase).Trim();
return n;
}
public static string NormalizeFontKey(string s){
if (string.IsNullOrWhiteSpace(s))
return string.Empty;
s = s.Trim().Trim('"');
if (s.StartsWith("@"))
s = s.Substring(1);
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
s = s.Replace('_', ' ').Replace('-', ' ');
s = Regex.Replace(s, @"\s+", " ").Trim();
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase);
return s;
}
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 sealed class FontIndex{
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
public void Rebuild(string fontsDir){
map.Clear();
if (!Directory.Exists(fontsDir)) return;
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
continue;
foreach (var desc in LoadDescriptions(path)){
foreach (var alias in BuildAliases(desc)){
Add(alias, path);
}
}
}
}
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();
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
var full = d.FontNameInvariantCulture.Trim(); // "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

@ -1,248 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Views;
namespace CRD.Utils.Muxing;
public class FontsManager{
#region Singelton
private static FontsManager? instance;
private static readonly object padlock = new object();
public static FontsManager Instance{
get{
if (instance == null){
lock (padlock){
if (instance == null){
instance = new FontsManager();
}
}
}
return instance;
}
}
#endregion
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
{ "Adobe Arabic", "AdobeArabic-Bold.otf" },
{ "Andale Mono", "andalemo.ttf" },
{ "Arial", "arial.ttf" },
{ "Arial Black", "ariblk.ttf" },
{ "Arial Bold", "arialbd.ttf" },
{ "Arial Bold Italic", "arialbi.ttf" },
{ "Arial Italic", "ariali.ttf" },
{ "Arial Unicode MS", "arialuni.ttf" },
{ "Comic Sans MS", "comic.ttf" },
{ "Comic Sans MS Bold", "comicbd.ttf" },
{ "Courier New", "cour.ttf" },
{ "Courier New Bold", "courbd.ttf" },
{ "Courier New Bold Italic", "courbi.ttf" },
{ "Courier New Italic", "couri.ttf" },
{ "DejaVu LGC Sans Mono", "DejaVuLGCSansMono.ttf" },
{ "DejaVu LGC Sans Mono Bold", "DejaVuLGCSansMono-Bold.ttf" },
{ "DejaVu LGC Sans Mono Bold Oblique", "DejaVuLGCSansMono-BoldOblique.ttf" },
{ "DejaVu LGC Sans Mono Oblique", "DejaVuLGCSansMono-Oblique.ttf" },
{ "DejaVu Sans", "DejaVuSans.ttf" },
{ "DejaVu Sans Bold", "DejaVuSans-Bold.ttf" },
{ "DejaVu Sans Bold Oblique", "DejaVuSans-BoldOblique.ttf" },
{ "DejaVu Sans Condensed", "DejaVuSansCondensed.ttf" },
{ "DejaVu Sans Condensed Bold", "DejaVuSansCondensed-Bold.ttf" },
{ "DejaVu Sans Condensed Bold Oblique", "DejaVuSansCondensed-BoldOblique.ttf" },
{ "DejaVu Sans Condensed Oblique", "DejaVuSansCondensed-Oblique.ttf" },
{ "DejaVu Sans ExtraLight", "DejaVuSans-ExtraLight.ttf" },
{ "DejaVu Sans Mono", "DejaVuSansMono.ttf" },
{ "DejaVu Sans Mono Bold", "DejaVuSansMono-Bold.ttf" },
{ "DejaVu Sans Mono Bold Oblique", "DejaVuSansMono-BoldOblique.ttf" },
{ "DejaVu Sans Mono Oblique", "DejaVuSansMono-Oblique.ttf" },
{ "DejaVu Sans Oblique", "DejaVuSans-Oblique.ttf" },
{ "Gautami", "gautami.ttf" },
{ "Georgia", "georgia.ttf" },
{ "Georgia Bold", "georgiab.ttf" },
{ "Georgia Bold Italic", "georgiaz.ttf" },
{ "Georgia Italic", "georgiai.ttf" },
{ "Impact", "impact.ttf" },
{ "Mangal", "MANGAL.woff2" },
{ "Meera Inimai", "MeeraInimai-Regular.ttf" },
{ "Noto Sans Tamil", "NotoSansTamilVariable.ttf" },
{ "Noto Sans Telugu", "NotoSansTeluguVariable.ttf" },
{ "Noto Sans Thai", "NotoSansThai.ttf" },
{ "Rubik", "Rubik-Regular.ttf" },
{ "Rubik Black", "Rubik-Black.ttf" },
{ "Rubik Black Italic", "Rubik-BlackItalic.ttf" },
{ "Rubik Bold", "Rubik-Bold.ttf" },
{ "Rubik Bold Italic", "Rubik-BoldItalic.ttf" },
{ "Rubik Italic", "Rubik-Italic.ttf" },
{ "Rubik Light", "Rubik-Light.ttf" },
{ "Rubik Light Italic", "Rubik-LightItalic.ttf" },
{ "Rubik Medium", "Rubik-Medium.ttf" },
{ "Rubik Medium Italic", "Rubik-MediumItalic.ttf" },
{ "Tahoma", "tahoma.ttf" },
{ "Times New Roman", "times.ttf" },
{ "Times New Roman Bold", "timesbd.ttf" },
{ "Times New Roman Bold Italic", "timesbi.ttf" },
{ "Times New Roman Italic", "timesi.ttf" },
{ "Trebuchet MS", "trebuc.ttf" },
{ "Trebuchet MS Bold", "trebucbd.ttf" },
{ "Trebuchet MS Bold Italic", "trebucbi.ttf" },
{ "Trebuchet MS Italic", "trebucit.ttf" },
{ "Verdana", "verdana.ttf" },
{ "Verdana Bold", "verdanab.ttf" },
{ "Verdana Bold Italic", "verdanaz.ttf" },
{ "Verdana Italic", "verdanai.ttf" },
{ "Vrinda", "vrinda.ttf" },
{ "Vrinda Bold", "vrindab.ttf" },
{ "Webdings", "webdings.ttf" }
};
public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts...");
var fonts = Fonts.Values.ToList();
foreach (var font in fonts){
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
// Console.WriteLine($"{font} already downloaded!");
} else{
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
File.Delete(fontLoc);
}
try{
if (!Directory.Exists(fontFolder)){
Directory.CreateDirectory(fontFolder);
}
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font;
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
}
}
Console.WriteLine("All required fonts downloaded!");
}
public static List<string> ExtractFontsFromAss(string ass){
var lines = ass.Replace("\r", "").Split('\n');
var styles = new List<string>();
foreach (var line in lines){
if (line.StartsWith("Style: ")){
var parts = line.Split(',');
if (parts.Length > 1)
styles.Add(parts[1].Trim());
}
}
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
foreach (Match match in fontMatches){
if (match.Groups.Count > 1)
styles.Add(match.Groups[1].Value);
}
return styles.Distinct().ToList(); // Using Linq to remove duplicates
}
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList){
Dictionary<string, string> filteredDictionary = new Dictionary<string, string>();
foreach (string key in keysList){
if (Fonts.TryGetValue(key, out var font)){
filteredDictionary.Add(key, font);
}
}
return filteredDictionary;
}
public static string GetFontMimeType(string fontFile){
if (Regex.IsMatch(fontFile, @"\.otf$"))
return "application/vnd.ms-opentype";
else if (Regex.IsMatch(fontFile, @"\.ttf$"))
return "application/x-truetype-font";
else
return "application/octet-stream";
}
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
Dictionary<string, string> fontsNameList = new Dictionary<string, string>();
List<string> subsList = new List<string>();
List<ParsedFont> fontsList = new List<ParsedFont>();
bool isNstr = true;
foreach (var s in subs){
foreach (var keyValuePair in s.Fonts){
if (!fontsNameList.ContainsKey(keyValuePair.Key)){
fontsNameList.Add(keyValuePair.Key, keyValuePair.Value);
}
}
subsList.Add(s.Language.Locale);
}
if (subsList.Count > 0){
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count);
isNstr = false;
}
if (fontsNameList.Count > 0){
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
}
List<string> missingFonts = new List<string>();
foreach (var f in fontsNameList){
if (Fonts.TryGetValue(f.Key, out var fontFile)){
string fontPath = Path.Combine(fontsDir, fontFile);
string mime = GetFontMimeType(fontFile);
if (File.Exists(fontPath) && new FileInfo(fontPath).Length != 0){
fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime });
}
} else{
missingFonts.Add(f.Key);
}
}
if (missingFonts.Count > 0){
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}");
}
return fontsList;
}
}
public class SubtitleFonts{
public LanguageItem Language{ get; set; }
public Dictionary<string, string> Fonts{ get; set; }
}

View file

@ -1,411 +1,29 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml; using CRD.Utils.Muxing.Commands;
using CRD.Utils.Files; using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing; namespace CRD.Utils.Muxing;
public class Merger{ public class Merger{
public MergerOptions options; public MergerOptions Options;
public Merger(MergerOptions options){ public Merger(MergerOptions options){
this.options = options; Options = options;
if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){
this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'"); if (Options.VideoTitle is{ Length: > 0 }){
Options.VideoTitle = Options.VideoTitle.Replace("\"", "'");
} }
} }
public string FFmpeg(){ public string FFmpeg(){
List<string> args = new List<string>(); return new FFmpegCommandBuilder(Options).Build();
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(){ public string MkvMerge(){
List<string> args = new List<string>(); return new MkvMergeCommandBuilder(Options).Build();
bool hasVideo = false;
args.Add($"-o \"{Helpers.AddUncPrefixIfNeeded(options.Output)}\"");
if (options.Options.mkvmerge != null){
args.AddRange(options.Options.mkvmerge);
}
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;
} }
@ -422,7 +40,7 @@ public class Merger{
} }
Console.WriteLine($"[{type}] Started merging"); Console.WriteLine($"[{type}] Started merging");
var result = await Helpers.ExecuteCommandAsync(type, bin, command); var result = await Helpers.ExecuteCommandAsync(bin, command);
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){ if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning"); Console.Error.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
@ -440,111 +58,21 @@ public class Merger{
public void CleanUp(){ public void CleanUp(){
// Combine all media file lists and iterate through them // Combine all media file lists and iterate through them
var allMediaFiles = options.OnlyAudio.Concat(options.OnlyVid) var allMediaFiles = Options.OnlyAudio.Concat(Options.OnlyVid).Concat(Options.VideoAndAudio)
.ToList(); .ToList();
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path)); allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path));
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".resume")); allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".resume"));
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".new.resume")); allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".new.resume"));
options.Description?.ForEach(description => Helpers.DeleteFile(description.Path)); Options.Description?.ForEach(description => Helpers.DeleteFile(description.Path));
Options.Cover.ForEach(cover => Helpers.DeleteFile(cover.Path));
options.Cover?.ForEach(cover => Helpers.DeleteFile(cover.Path));
// Delete chapter files if any // 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 // 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

@ -0,0 +1,34 @@
using System.Collections.Generic;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Structs;
public class CrunchyMuxOptions{
public required List<string> DubLangList{ get; set; } = [];
public required List<string> SubLangList{ get; set; } = [];
public string Output{ get; set; }
public bool SkipSubMux{ get; set; }
public bool KeepAllVideos{ get; set; }
public bool Novids{ get; set; }
public bool Mp4{ get; set; }
public bool Mp3{ get; set; }
public bool MuxFonts{ get; set; }
public bool MuxCover{ get; set; }
public bool MuxDescription{ get; set; }
public string ForceMuxer{ get; set; }
public bool NoCleanup{ get; set; }
public string VideoTitle{ get; set; }
public List<string> FfmpegOptions{ get; set; } = [];
public List<string> MkvmergeOptions{ get; set; } = [];
public LanguageItem? DefaultSub{ get; set; }
public LanguageItem? DefaultAudio{ get; set; }
public string CcTag{ get; set; }
public bool SyncTiming{ get; set; }
public bool DlVideoOnce{ get; set; }
public bool DefaultSubSigns{ get; set; }
public bool DefaultSubForcedDisplay{ get; set; }
public bool CcSubsMuxingFlag{ get; set; }
public bool SignsSubsAsForced{ get; set; }
}

View file

@ -0,0 +1,12 @@
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Structs;
public class MergerInput{
public string Path{ get; set; }
public LanguageItem Language{ get; set; }
public int? Duration{ get; set; }
public int? Delay{ get; set; }
public bool IsAudioRoleDescription{ get; set; }
public int? Bitrate{ get; set; }
}

View file

@ -0,0 +1,39 @@
using System.Collections.Generic;
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Structs;
public class MergerOptions{
public List<string> DubLangList{ get; set; } = [];
public List<string> SubLangList{ get; set; } = [];
public List<MergerInput> OnlyVid{ get; set; } = [];
public List<MergerInput> OnlyAudio{ get; set; } = [];
public List<SubtitleInput> Subtitles{ get; set; } = [];
public List<MergerInput> Chapters{ get; set; } = [];
public string CcTag{ get; set; }
public string Output{ get; set; }
public string VideoTitle{ get; set; }
public bool KeepAllVideos{ get; set; }
public List<ParsedFont> Fonts{ get; set; } = [];
public bool SkipSubMux{ get; set; }
public MuxOptions Options{ get; set; }
public Defaults Defaults{ get; set; }
public bool mp3{ get; set; }
public bool DefaultSubSigns{ get; set; }
public bool DefaultSubForcedDisplay{ get; set; }
public bool CcSubsMuxingFlag{ get; set; }
public bool SignsSubsAsForced{ get; set; }
public List<MergerInput> Description{ get; set; } = [];
public List<MergerInput> Cover{ get; set; } = [];
public List<MergerInput> VideoAndAudio{ get; set; } = [];
}
public class Defaults{
public LanguageItem? Audio{ get; set; }
public LanguageItem? Sub{ get; set; }
}
public class MuxOptions{
public List<string>? Ffmpeg{ get; set; }
public List<string>? Mkvmerge{ get; set; }
}

View file

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

View file

@ -0,0 +1,13 @@
using CRD.Utils.Structs;
namespace CRD.Utils.Muxing.Structs;
public class SubtitleInput{
public LanguageItem Language{ get; set; }
public string File{ get; set; }
public bool ClosedCaption{ get; set; }
public bool Signs{ get; set; }
public int? Delay{ get; set; }
public DownloadedMedia? RelatedVideoDownloadMedia;
}

View file

@ -1,10 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
@ -13,7 +14,7 @@ using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image; using Image = SixLabors.ImageSharp.Image;
namespace CRD.Utils.Muxing; namespace CRD.Utils.Muxing.Syncing;
public class SyncingHelper{ public class SyncingHelper{
public static async Task<(bool IsOk, int ErrorCode, double frameRate)> ExtractFrames(string videoPath, string outputDir, double offset, double duration){ public static async Task<(bool IsOk, int ErrorCode, double frameRate)> ExtractFrames(string videoPath, string outputDir, double offset, double duration){

View file

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

View file

@ -216,7 +216,7 @@ public class ToM3u8Class{
} }
public static dynamic FormatVttPlaylist(dynamic item){ public static dynamic FormatVttPlaylist(dynamic item){
if (ObjectUtilities.GetMemberValue(item,"segments") == null){ if (ObjectUtilities.GetMemberValue(item, "segments") == null){
// VTT tracks may use a single file in BaseURL // VTT tracks may use a single file in BaseURL
var segment = new ExpandoObject() as IDictionary<string, object>; var segment = new ExpandoObject() as IDictionary<string, object>;
segment["uri"] = item.attributes.baseUrl; segment["uri"] = item.attributes.baseUrl;
@ -237,8 +237,7 @@ public class ToM3u8Class{
m3u8Attributes["PROGRAM-ID"] = 1; m3u8Attributes["PROGRAM-ID"] = 1;
if (ObjectUtilities.GetMemberValue(item.attributes, "codecs") != null){
if (ObjectUtilities.GetMemberValue(item.attributes,"codecs") != null){
m3u8Attributes["CODECS"] = item.attributes.codecs; m3u8Attributes["CODECS"] = item.attributes.codecs;
} }
@ -252,7 +251,7 @@ public class ToM3u8Class{
vttPlaylist.timelineStarts = item.attributes.timelineStarts; vttPlaylist.timelineStarts = item.attributes.timelineStarts;
vttPlaylist.discontinuityStarts = item.discontinuityStarts; vttPlaylist.discontinuityStarts = item.discontinuityStarts;
vttPlaylist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence"); vttPlaylist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence");
vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item,"mediaSequence"); vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item, "mediaSequence");
vttPlaylist.segments = item.segments; vttPlaylist.segments = item.segments;
if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){ if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){
@ -365,7 +364,7 @@ public class ToM3u8Class{
} }
return allPlaylists.Select(playlist => { return allPlaylists.Select(playlist => {
playlist.discontinuityStarts = FindIndexes((List<dynamic>) ObjectUtilities.GetMemberValue(playlists,"segments") ?? new List<dynamic>(), "discontinuity"); playlist.discontinuityStarts = FindIndexes((List<dynamic>)ObjectUtilities.GetMemberValue(playlists, "segments") ?? new List<dynamic>(), "discontinuity");
return playlist; return playlist;
}).ToList(); }).ToList();
} }
@ -442,21 +441,26 @@ public class ToM3u8Class{
public static void AddMediaSequenceValues(List<dynamic> playlists, List<dynamic> timelineStarts){ public static void AddMediaSequenceValues(List<dynamic> playlists, List<dynamic> timelineStarts){
foreach (var playlist in playlists){ foreach (var playlist in playlists){
playlist.mediaSequence = 0; playlist.mediaSequence = 0;
playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
if (playlist.segments == null) continue; playlist.discontinuitySequence =
timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
for (int i = 0; i < playlist.segments.Count; i++){ var segments = playlist.segments as List<dynamic>;
playlist.segments[i].number = i; if (segments == null) continue;
for (int i = 0; i < segments.Count; i++){
segments[i].number = i;
} }
} }
} }
public static List<int> FindIndexes(List<dynamic> list, string key){ public static List<int> FindIndexes(List<dynamic> list, string key){
var indexes = new List<int>(); var indexes = new List<int>(list.Count);
for (int i = 0; i < list.Count; i++){ for (int i = 0; i < list.Count; i++){
var expandoDict = list[i] as IDictionary<string, object>; if (list[i] is IDictionary<string, object?> dict &&
if (expandoDict != null && expandoDict.ContainsKey(key) && expandoDict[key] != null){ dict.TryGetValue(key, out var value) &&
value != null){
indexes.Add(i); indexes.Add(i);
} }
} }
@ -464,33 +468,50 @@ public class ToM3u8Class{
return indexes; return indexes;
} }
public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, IDictionary<string, dynamic> sidxMapping){ public static dynamic AddSidxSegmentsToPlaylist(
string sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx")); dynamic playlist,
if (!string.IsNullOrEmpty(sidxKey) && sidxMapping.ContainsKey(sidxKey)){ IDictionary<string, dynamic> sidxMapping){
var sidxMatch = sidxMapping[sidxKey]; string? sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx"));
if (sidxMatch != null){
SegmentBase.AddSidxSegmentsToPlaylist(playlist, sidxMatch.sidx, playlist.sidx.resolvedUri); if (string.IsNullOrEmpty(sidxKey))
} return playlist;
}
if (!sidxMapping.TryGetValue(sidxKey, out var sidxMatch) || sidxMatch == null)
return playlist;
SegmentBase.AddSidxSegmentsToPlaylist(
playlist,
sidxMatch?.sidx,
ObjectUtilities.GetMemberValue(playlist.sidx, "resolvedUri"));
return playlist; 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>(); sidxMapping ??= new Dictionary<string, dynamic>();
if (sidxMapping.Count == 0){ if (sidxMapping.Count == 0)
return playlists; return playlists;
}
for (int i = 0; i < playlists.Count; i++){ foreach (var playlist in playlists){
playlists[i] = AddSidxSegmentsToPlaylist(playlists[i], sidxMapping); AddSidxSegmentsToPlaylist(playlist, sidxMapping);
} }
return playlists; return playlists;
} }
public static string GenerateSidxKey(dynamic sidx){ public static string? GenerateSidxKey(dynamic sidx){
return sidx != null ? $"{sidx.uri}-{UrlType.ByteRangeToString(sidx.byterange)}" : null; 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)}";
} }
} }

View file

@ -1,14 +1,18 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Dynamic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Linq; using System.Xml.Linq;
using CRD.Utils.DRM; using CRD.Utils.DRM;
using CRD.Utils.HLS; using CRD.Utils.HLS;
using CRD.Utils.Parser; using CRD.Utils.Http;
using CRD.Utils.Parser.Utils; using CRD.Utils.Parser.Utils;
using CRD.Utils.Structs; using CRD.Utils.Structs;
namespace CRD.Utils; namespace CRD.Utils.Parser;
public class Segment{ public class Segment{
public string uri{ get; set; } public string uri{ get; set; }
@ -16,40 +20,41 @@ public class Segment{
public double duration{ get; set; } public double duration{ get; set; }
public Map map{ get; set; } public Map map{ get; set; }
public ByteRange? byteRange { get; set; } public ByteRange? byteRange{ get; set; }
public double? number{ get; set; } public double? number{ get; set; }
public double? presentationTime{ get; set; } public double? presentationTime{ get; set; }
} }
public class Map{ public class Map{
public string uri { get; set; } public string uri{ get; set; }
public ByteRange? byteRange { get; set; } public ByteRange? byteRange{ get; set; }
} }
public class PlaylistItem{ public class PlaylistItem{
public string? pssh{ get; set; } public string? pssh{ get; set; }
public List<ContentKey> encryptionKeys{ get; set; } =[];
public List<ContentKey> encryptionKeys{ get; set; } = [];
public int bandwidth{ get; set; } public int bandwidth{ get; set; }
public List<Segment> segments{ get; set; } public List<Segment> segments{ get; set; }
} }
public class AudioPlaylist : PlaylistItem{ public class AudioPlaylist : PlaylistItem{
public LanguageItem? language{ get; set; } public LanguageItem? language{ get; set; }
public int audioSamplingRate{ get; set; } public int audioSamplingRate{ get; set; }
public bool @default{ get; set; } public bool @default{ get; set; }
} }
public class VideoPlaylist : PlaylistItem{ public class VideoPlaylist : PlaylistItem{
public Quality quality{ get; set; } public Quality quality{ get; set; }
public string codecs{ get; set; }
} }
public class VideoItem: VideoPlaylist{ public class VideoItem : VideoPlaylist{
public string resolutionText{ get; set; } public string resolutionText{ get; set; }
} }
public class AudioItem: AudioPlaylist{ public class AudioItem : AudioPlaylist{
public string resolutionText{ get; set; } public string resolutionText{ get; set; }
public string resolutionTextSnap{ get; set; } public string resolutionTextSnap{ get; set; }
} }
@ -65,16 +70,16 @@ public class MPDParsed{
public class ServerData{ public class ServerData{
public List<string> servers{ get; set; } = []; public List<string> servers{ get; set; } = [];
public List<AudioPlaylist>? audio{ get; set; } =[]; public List<AudioPlaylist>? audio{ get; set; } = [];
public List<VideoPlaylist>? video{ get; set; } =[]; public List<VideoPlaylist>? video{ get; set; } = [];
} }
public static class MPDParser{ public static class MpdParser{
public static MPDParsed Parse(string manifest, LanguageItem? language, string? url){ public async static Task<MPDParsed> Parse(string manifest, LanguageItem? language, string? url){
if (!manifest.Contains("BaseURL") && url != null){ if (!manifest.Contains("BaseURL") && url != null){
XDocument doc = XDocument.Parse(manifest); XDocument doc = XDocument.Parse(manifest);
XElement mpd = doc.Element("MPD"); XElement? mpd = doc.Element("MPD");
mpd.AddFirst(new XElement("BaseURL", url)); mpd?.AddFirst(new XElement("BaseURL", url));
manifest = doc.ToString(); manifest = doc.ToString();
} }
@ -84,90 +89,295 @@ public static class MPDParser{
foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){ foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){
foreach (var playlist in item.playlists){ foreach (var playlist in item.playlists){
var host = new Uri(playlist.resolvedUri).Host; var uri = new Uri(playlist.resolvedUri);
var host = uri.Host;
EnsureHostEntryExists(ret, host); EnsureHostEntryExists(ret, host);
List<dynamic> segments = playlist.segments; List<dynamic> segments = playlist.segments;
List<Segment>? segmentsFromSidx = null;
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){ if (ObjectUtilities.GetMemberValue(playlist, "sidx") != null){
throw new NotImplementedException(); if (segments == null || segments.Count == 0){
var sidxRange = playlist.sidx.ByteRange;
var sidxBytes = await DownloadSidxAsync(
HttpClientReq.Instance.GetHttpClient(),
playlist.sidx.uri,
sidxRange.offset,
sidxRange.offset + sidxRange.length - 1);
var sidx = ParseSidx(sidxBytes, sidxRange.offset);
var byteRange = new ByteRange(){
Length = playlist.sidx.map.ByteRange.length,
Offset = playlist.sidx.map.ByteRange.offset,
};
segmentsFromSidx = BuildSegmentsFromSidx(
sidx,
playlist.resolvedUri,
byteRange);
}
} }
var foundLanguage = Languages.FindLang(Languages.languages.FirstOrDefault(a => a.CrLocale == item.language)?.CrLocale ?? "unknown");
LanguageItem? audioLang = item.language != null ? foundLanguage : (language != null ? language : foundLanguage); var foundLanguage =
Languages.FindLang(
Languages.languages.FirstOrDefault(a => a.CrLocale == item.language)?.CrLocale ?? "unknown"
);
LanguageItem? audioLang =
item.language != null
? foundLanguage
: (language ?? foundLanguage);
var pItem = new AudioPlaylist{ var pItem = new AudioPlaylist{
bandwidth = playlist.attributes.BANDWIDTH, bandwidth = playlist.attributes.BANDWIDTH,
audioSamplingRate = ObjectUtilities.GetMemberValue(playlist.attributes ,"AUDIOSAMPLINGRATE") ?? 0, audioSamplingRate = ObjectUtilities.GetMemberValue(playlist.attributes, "AUDIOSAMPLINGRATE") ?? 0,
language = audioLang, language = audioLang,
@default = item.@default, @default = item.@default,
segments = segments.Select(segment => new Segment{ segments = segmentsFromSidx ?? ConvertSegments(segments)
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()
}; };
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection"); pItem.pssh = ExtractWidevinePssh(playlist);
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null) ret.Data[host].audio?.Add(pItem);
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
ret.Data[host].audio.Add(pItem);
} }
} }
foreach (var playlist in parsed.playlists){ foreach (var playlist in parsed.playlists){
var host = new Uri(playlist.resolvedUri).Host; var uri = new Uri(playlist.resolvedUri);
var host = uri.Host;
EnsureHostEntryExists(ret, host); EnsureHostEntryExists(ret, host);
List<dynamic> segments = playlist.segments; List<dynamic> segments = playlist.segments;
List<Segment>? segmentsFromSidx = null;
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){ if (ObjectUtilities.GetMemberValue(playlist, "sidx") != null){
throw new NotImplementedException(); if (segments == null || segments.Count == 0){
var sidxRange = playlist.sidx.ByteRange;
var sidxBytes = await DownloadSidxAsync(
HttpClientReq.Instance.GetHttpClient(),
playlist.sidx.uri,
sidxRange.offset,
sidxRange.offset + sidxRange.length - 1);
var sidx = ParseSidx(sidxBytes, sidxRange.offset);
var byteRange = new ByteRange(){
Length = playlist.sidx.map.ByteRange.length,
Offset = playlist.sidx.map.ByteRange.offset,
};
segmentsFromSidx = BuildSegmentsFromSidx(
sidx,
playlist.resolvedUri,
byteRange);
}
} }
dynamic resolution =
dynamic resolution = ObjectUtilities.GetMemberValue(playlist.attributes,"RESOLUTION"); ObjectUtilities.GetMemberValue(playlist.attributes, "RESOLUTION") ?? new Quality();
resolution = resolution != null ? resolution : new Quality();
var pItem = new VideoPlaylist{ var pItem = new VideoPlaylist{
bandwidth = playlist.attributes.BANDWIDTH, bandwidth = playlist.attributes.BANDWIDTH,
quality = new Quality{height = resolution.height,width = resolution.width}, codecs = ObjectUtilities.GetMemberValue(playlist.attributes, "CODECS") ?? "",
segments = segments.Select(segment => new Segment{ quality = new Quality{
duration = segment.duration, height = resolution.height,
map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")}, width = resolution.width
number = segment.number, },
presentationTime = segment.presentationTime, segments = segmentsFromSidx ?? ConvertSegments(segments)
timeline = segment.timeline,
uri = segment.resolvedUri,
byteRange = ObjectUtilities.GetMemberValue(segment,"byterange")
}).ToList()
}; };
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection"); pItem.pssh = ExtractWidevinePssh(playlist);
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null) ret.Data[host].video?.Add(pItem);
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
ret.Data[host].video.Add(pItem);
} }
return ret; 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){ private static void EnsureHostEntryExists(MPDParsed ret, string host){
if (!ret.Data.ContainsKey(host)){ if (!ret.Data.ContainsKey(host)){
ret.Data[host] = new ServerData{ audio = new List<AudioPlaylist>(), video = new List<VideoPlaylist>() }; ret.Data[host] = new ServerData{
audio = new List<AudioPlaylist>(),
video = new List<VideoPlaylist>()
};
} }
} }
public static string ArrayBufferToBase64(byte[] buffer){ public static List<Segment> BuildSegmentsFromSidx(
return Convert.ToBase64String(buffer); 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; }
} }
} }

View file

@ -20,27 +20,38 @@ public class InheritAttributes{
var keySystemInfo = new ExpandoObject() as IDictionary<string, object>; var keySystemInfo = new ExpandoObject() as IDictionary<string, object>;
foreach (var node in contentProtectionNodes){ foreach (var node in contentProtectionNodes){
dynamic attributes = ParseAttribute.ParseAttributes(node); // Assume this returns a dictionary dynamic attributes = ParseAttribute.ParseAttributes(node);
var testAttributes = attributes as IDictionary<string, object>; var attributesDict = attributes as IDictionary<string, object>;
if (testAttributes != null && testAttributes.TryGetValue("schemeIdUri", out var attribute)){ if (attributesDict == null)
string? schemeIdUri = attribute.ToString()?.ToLower(); continue;
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 (!attributesDict.TryGetValue("schemeIdUri", out var schemeObj))
if (psshNode != null){ continue;
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 var schemeIdUri = schemeObj?.ToString()?.ToLower();
keySystemInfo[keySystem] = info;
} if (schemeIdUri == null)
continue;
attributesDict["schemeIdUri"] = schemeIdUri;
if (!KeySystemsMap.TryGetValue(schemeIdUri, out var keySystem))
continue;
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);
} }
keySystemInfo[keySystem] = info;
} }
return keySystemInfo; return keySystemInfo;
@ -82,8 +93,6 @@ public class InheritAttributes{
} }
public static double? GetPeriodStart(dynamic attributes, dynamic? priorPeriodAttributes, string mpdType){ public static double? GetPeriodStart(dynamic attributes, dynamic? priorPeriodAttributes, string mpdType){
// Summary of period start time calculation from DASH spec section 5.3.2.1 // Summary of period start time calculation from DASH spec section 5.3.2.1
// //
@ -179,14 +188,14 @@ public class InheritAttributes{
var segmentInitializationParentNode = segmentList ?? segmentBase ?? segmentTemplate; var segmentInitializationParentNode = segmentList ?? segmentBase ?? segmentTemplate;
var segmentInitialization = segmentInitializationParentNode != null ? XMLUtils.FindChildren(segmentInitializationParentNode, "Initialization").FirstOrDefault() : null; 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){ if (template != null && segmentInitialization != null){
template.initialization = ParseAttribute.ParseAttributes(segmentInitialization); template?.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
} else if (template != null && template.initialization != null){ } else if (template != null && template?.initialization != null){
dynamic init = new ExpandoObject(); dynamic init = new ExpandoObject();
init.sourceURL = template.initialization; init.sourceURL = template?.initialization;
template.initialization = init; template?.initialization = init;
} }
segmentInfo.template = template; segmentInfo.template = template;
@ -194,10 +203,14 @@ public class InheritAttributes{
segmentInfo.list = segmentList != null segmentInfo.list = segmentList != null
? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, initialization = ParseAttribute.ParseAttributes(segmentInitialization) })
: null; : null;
segmentInfo.baseInfo = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase), new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) : null;
dynamic initBas = new ExpandoObject();
initBas.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
//new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }
segmentInfo.@base = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase),initBas ) : null;
// Clean up null entries // Clean up null entries
var dict = (IDictionary<string, object>)segmentInfo; var dict = (IDictionary<string, object?>)segmentInfo;
var keys = dict.Keys.ToList(); var keys = dict.Keys.ToList();
foreach (var key in keys){ foreach (var key in keys){
if (dict[key] == null){ if (dict[key] == null){
@ -301,24 +314,25 @@ public class InheritAttributes{
attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes); attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes);
var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault(); var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault();
var captionServices = ParseCaptionServiceMetadata(ParseAttribute.ParseAttributes(accessibility)); var captionServices = ParseCaptionServiceMetadata(accessibility != null ? ParseAttribute.ParseAttributes(accessibility) : null);
if (captionServices != null){ if (captionServices != null){
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices }); attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices });
} }
XmlElement label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault(); XmlElement? label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
if (label != null && label.ChildNodes.Count > 0){ if (label is{ ChildNodes.Count: > 0 }){
var labelVal = label.ChildNodes[0].ToString().Trim(); var labelVal = label.ChildNodes[0]?.Value?.Trim();
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal }); attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal });
} }
var contentProtection = GenerateKeySystemInformation(XMLUtils.FindChildren(adaptationSet, "ContentProtection")); var nodes = XMLUtils.FindChildren(adaptationSet, "ContentProtection");
var contentProtection = GenerateKeySystemInformation(nodes);
var tempTestContentProtection = contentProtection as IDictionary<string, Object>; var tempTestContentProtection = contentProtection as IDictionary<string, Object>;
if (tempTestContentProtection != null && tempTestContentProtection.Count > 0){ if (tempTestContentProtection is{ Count: > 0 }){
dynamic contentProt = new ExpandoObject(); dynamic contentProt = new ExpandoObject();
contentProt.contentProtection = contentProtection; contentProt.contentProtection = contentProtection;
attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt ); attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt);
} }
var segmentInfo = GetSegmentInformation(adaptationSet); var segmentInfo = GetSegmentInformation(adaptationSet);
@ -337,16 +351,28 @@ public class InheritAttributes{
return list; return list;
} }
public static List<dynamic> InheritBaseUrls(dynamic adaptationSetAttributes, dynamic adaptationSetBaseUrls, dynamic adaptationSetSegmentInfo, XmlElement representation){ public static List<dynamic> InheritBaseUrls(dynamic adaptationSetAttributes, dynamic adaptationSetBaseUrls, dynamic adaptationSetSegmentInfo, XmlElement representation){
var repBaseUrlElements = XMLUtils.FindChildren(representation, "BaseURL"); var repBaseUrlElements = XMLUtils.FindChildren(representation, "BaseURL");
List<dynamic> repBaseUrls = BuildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements); List<dynamic> repBaseUrls = BuildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements);
var attributes = ObjectUtilities.MergeExpandoObjects(adaptationSetAttributes, ParseAttribute.ParseAttributes(representation)); 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); var representationSegmentInfo = GetSegmentInformation(representation);
return repBaseUrls.Select(baseUrl => { return repBaseUrls.Select(baseUrl => {
dynamic result = new ExpandoObject(); dynamic result = new ExpandoObject();
result.segmentInfo = ObjectUtilities.MergeExpandoObjects(adaptationSetSegmentInfo, representationSegmentInfo); result.segmentInfo = ObjectUtilities.MergeExpandoObjects(adaptationSetSegmentInfo, representationSegmentInfo);
result.attributes = ObjectUtilities.MergeExpandoObjects(attributes, baseUrl); result.attributes = ObjectUtilities.MergeExpandoObjects(attributes, baseUrl);
return result; return result;
}).ToList(); }).ToList();
} }

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>; var expandoObj = new ExpandoObject() as IDictionary<string, object>;
if (el != null && el.Attributes != null){ if (el is{ Attributes: not null }){
foreach (XmlAttribute attr in el.Attributes){ foreach (XmlAttribute attr in el.Attributes){
Func<string, object> parseFn; Func<string, object>? parseFn;
if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){ if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){
expandoObj[attr.Name] = parseFn(attr.Value); expandoObj[attr.Name] = parseFn(attr.Value);
} else{ } else{

View file

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

View file

@ -3,32 +3,34 @@ using System.Collections.Generic;
using System.Dynamic; using System.Dynamic;
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using CRD.Utils.Parser.Utils;
namespace CRD.Utils.Parser.Segments; namespace CRD.Utils.Parser.Segments;
public class SegmentBase{ public class SegmentBase{
public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic> segmentTimeline){ public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic>? segmentTimeline = null){
if (attributes.baseUrl == null){ var baseUrl = ObjectUtilities.GetMemberValue(attributes, "baseUrl");
var initialization = ObjectUtilities.GetMemberValue(attributes, "initialization") ?? new ExpandoObject();
var sourceDuration = ObjectUtilities.GetMemberValue(attributes, "sourceDuration");
var indexRange = ObjectUtilities.GetMemberValue(attributes, "indexRange") ?? "";
var periodStart = ObjectUtilities.GetMemberValue(attributes, "periodStart");
var presentationTime = ObjectUtilities.GetMemberValue(attributes, "presentationTime");
var number = ObjectUtilities.GetMemberValue(attributes, "number") ?? 0;
var duration = ObjectUtilities.GetMemberValue(attributes, "duration");
if (baseUrl == null){
throw new Exception("NO_BASE_URL"); 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{ dynamic initSegment = UrlType.UrlTypeToSegment(new{
baseUrl = attributes.baseUrl, baseUrl = baseUrl,
source = initialization.sourceURL, source = ObjectUtilities.GetMemberValue(initialization, "sourceURL"),
range = initialization.range range = ObjectUtilities.GetMemberValue(initialization, "range")
}); });
dynamic segment = UrlType.UrlTypeToSegment(new{ dynamic segment = UrlType.UrlTypeToSegment(new{
baseUrl = attributes.baseUrl, baseUrl = baseUrl,
source = attributes.baseUrl, source = baseUrl,
indexRange = indexRange indexRange = indexRange
}); });
@ -36,6 +38,7 @@ public class SegmentBase{
if (duration != null){ if (duration != null){
var segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes); var segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes);
if (segmentTimeInfo.Count > 0){ if (segmentTimeInfo.Count > 0){
segment.duration = segmentTimeInfo[0].duration; segment.duration = segmentTimeInfo[0].duration;
segment.timeline = segmentTimeInfo[0].timeline; segment.timeline = segmentTimeInfo[0].timeline;
@ -46,6 +49,7 @@ public class SegmentBase{
} }
segment.presentationTime = presentationTime ?? periodStart; segment.presentationTime = presentationTime ?? periodStart;
segment.number = number; segment.number = number;
return new List<dynamic>{ segment }; return new List<dynamic>{ segment };

View file

@ -1,23 +1,34 @@
using System; using System;
using System.Dynamic;
using CRD.Utils.Parser.Utils; using CRD.Utils.Parser.Utils;
namespace CRD.Utils.Parser.Segments; namespace CRD.Utils.Parser.Segments;
public class UrlType{ public class UrlType{
public static dynamic UrlTypeToSegment(dynamic input){ public static dynamic UrlTypeToSegment(dynamic input){
dynamic segment = new { string baseUrl = Convert.ToString(ObjectUtilities.GetMemberValue(input, "baseUrl"));
uri = ObjectUtilities.GetMemberValue(input,"source"), string source = Convert.ToString(ObjectUtilities.GetMemberValue(input, "source"));
resolvedUri = new Uri(new Uri(input.baseUrl, UriKind.Absolute), input.source).ToString()
}; var baseUri = new Uri(baseUrl, UriKind.Absolute);
dynamic segment = new ExpandoObject();
segment.uri = source;
segment.resolvedUri = new Uri(baseUri, source).ToString();
string rangeStr = Convert.ToString(
!string.IsNullOrEmpty(Convert.ToString(ObjectUtilities.GetMemberValue(input, "range")))
? ObjectUtilities.GetMemberValue(input, "range")
: ObjectUtilities.GetMemberValue(input, "indexRange")
);
string rangeStr = !string.IsNullOrEmpty(input.range) ? ObjectUtilities.GetMemberValue(input,"range") : ObjectUtilities.GetMemberValue(input,"indexRange");
if (!string.IsNullOrEmpty(rangeStr)){ if (!string.IsNullOrEmpty(rangeStr)){
var ranges = rangeStr.Split('-'); var ranges = rangeStr.Split('-');
long startRange = long.Parse(ranges[0]); long startRange = long.Parse(ranges[0]);
long endRange = long.Parse(ranges[1]); long endRange = long.Parse(ranges[1]);
long length = endRange - startRange + 1; long length = endRange - startRange + 1;
segment.ByteRange = new { segment.ByteRange = new{
length = length, length = length,
offset = startRange offset = startRange
}; };

View file

@ -83,12 +83,12 @@ public class ObjectUtilities{
targetDict[fieldToSet] = valueToSet; 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 // First, check if the object is indeed an ExpandoObject
if (obj is ExpandoObject expando){ if (obj is ExpandoObject expando){
// Try to get the value from the ExpandoObject // Try to get the value from the ExpandoObject
var dictionary = (IDictionary<string, object>)expando; var dictionary = (IDictionary<string, object?>)expando;
if (dictionary.TryGetValue(memberName, out object value)){ if (dictionary.TryGetValue(memberName, out object? value)){
// Return the found value, which could be null // Return the found value, which could be null
return value; return value;
} }

View file

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

View file

@ -0,0 +1,83 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils;
public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposable{
private CancellationTokenSource? cts;
private Task? loopTask;
private TimeSpan currentInterval;
public DateTime LastRunTime = DateTime.MinValue;
public void StartOrRestart(TimeSpan interval, bool runImmediately = false, bool force = false){
if (interval <= TimeSpan.Zero){
Stop();
currentInterval = Timeout.InfiniteTimeSpan;
return;
}
if (!force && interval == currentInterval){
return;
}
currentInterval = interval;
Stop();
cts = new CancellationTokenSource();
loopTask = RunLoopAsync(interval, runImmediately, cts.Token);
}
public void StartOrRestartMinutes(int minutes, bool runImmediately = false, bool force = false)
=> StartOrRestart(TimeSpan.FromMinutes(minutes), runImmediately);
public void Stop(){
if (cts is null) return;
try{
cts.Cancel();
} finally{
cts.Dispose();
cts = null;
}
}
private async Task RunLoopAsync(TimeSpan interval, bool runImmediately, CancellationToken token){
if (runImmediately){
await SafeRunWork(token).ConfigureAwait(false);
}
using var timer = new PeriodicTimer(interval);
try{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)){
await SafeRunWork(token).ConfigureAwait(false);
}
} catch (OperationCanceledException){
}
}
private int running = 0;
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,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace CRD.Utils.Sonarr.Models; namespace CRD.Utils.Sonarr.Models;
@ -13,6 +14,15 @@ public class SonarrEpisode{
[JsonProperty("seriesId")] [JsonProperty("seriesId")]
public int SeriesId{ get; set; } 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> /// <summary>
/// Gets or sets the episode file identifier. /// Gets or sets the episode file identifier.
/// </summary> /// </summary>
@ -138,4 +148,8 @@ public class SonarrEpisode{
/// </value> /// </value>
[JsonProperty("id")] [JsonProperty("id")]
public int Id{ get; set; } public int Id{ get; set; }
[JsonProperty("series")]
public SonarrSeries? Series{ get; set; }
} }

View file

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

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using CRD.Utils.Http;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.ViewModels; using CRD.ViewModels;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -8,6 +9,9 @@ namespace CRD.Utils.Structs.Crunchyroll;
public class CrDownloadOptions{ public class CrDownloadOptions{
#region General Settings #region General Settings
[JsonProperty("gh_update_prereleases")]
public bool GhUpdatePrereleases{ get; set; }
[JsonProperty("shutdown_when_queue_empty")] [JsonProperty("shutdown_when_queue_empty")]
public bool ShutdownWhenQueueEmpty{ get; set; } public bool ShutdownWhenQueueEmpty{ get; set; }
@ -56,6 +60,14 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_sound_path")] [JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; } 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")] [JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; } public double BackgroundImageOpacity{ get; set; }
@ -93,6 +105,13 @@ public class CrDownloadOptions{
[JsonProperty("history_count_sonarr")] [JsonProperty("history_count_sonarr")]
public bool HistoryCountSonarr{ get; set; } 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")] [JsonProperty("sonarr_properties")]
public SonarrProperties? SonarrProperties{ get; set; } public SonarrProperties? SonarrProperties{ get; set; }
@ -141,6 +160,20 @@ public class CrDownloadOptions{
[JsonProperty("flare_solverr_properties")] [JsonProperty("flare_solverr_properties")]
public FlareSolverrProperties? FlareSolverrProperties{ get; set; } 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 #endregion
@ -236,6 +269,9 @@ public class CrDownloadOptions{
[JsonProperty("mux_fonts")] [JsonProperty("mux_fonts")]
public bool MuxFonts{ get; set; } public bool MuxFonts{ get; set; }
[JsonProperty("mux_typesetting_fonts")]
public bool MuxTypesettingFonts{ get; set; }
[JsonProperty("mux_cover")] [JsonProperty("mux_cover")]
public bool MuxCover{ get; set; } public bool MuxCover{ get; set; }
@ -311,6 +347,9 @@ public class CrDownloadOptions{
[JsonProperty("calendar_show_upcoming_episodes")] [JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; } public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("stream_endpoint_settings")] [JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; } public CrAuthSettings? StreamEndpoint{ get; set; }

View file

@ -4,7 +4,20 @@ using Newtonsoft.Json;
namespace CRD.Utils.Structs.Crunchyroll; 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 class CrProfile{
public string? Avatar{ get; set; } public string? Avatar{ get; set; }
public string? Email{ get; set; } public string? Email{ get; set; }
public string? Username{ get; set; } public string? Username{ get; set; }
@ -20,8 +33,14 @@ public class CrProfile{
[JsonProperty("preferred_content_subtitle_language")] [JsonProperty("preferred_content_subtitle_language")]
public string? PreferredContentSubtitleLanguage{ get; set; } public string? PreferredContentSubtitleLanguage{ get; set; }
[JsonIgnore] [JsonProperty("can_switch")]
public Subscription? Subscription{ get; set; } public bool CanSwitch{ get; set; }
[JsonProperty("is_selected")]
public bool IsSelected{ get; set; }
[JsonProperty("is_pin_protected")]
public bool IsPinProtected{ get; set; }
[JsonIgnore] [JsonIgnore]
public bool HasPremium{ get; set; } public bool HasPremium{ get; set; }

View file

@ -190,7 +190,7 @@ public class CrBrowseEpisodeVersion{
public Locale? AudioLocale{ get; set; } public Locale? AudioLocale{ get; set; }
public string? Guid{ get; set; } public string? Guid{ get; set; }
public bool? Original{ get; set; } public bool Original{ get; set; }
public string? Variant{ get; set; } public string? Variant{ get; set; }
[JsonProperty("season_guid")] [JsonProperty("season_guid")]
@ -200,6 +200,6 @@ public class CrBrowseEpisodeVersion{
public string? MediaGuid{ get; set; } public string? MediaGuid{ get; set; }
[JsonProperty("is_premium_only")] [JsonProperty("is_premium_only")]
public bool? IsPremiumOnly{ get; set; } public bool IsPremiumOnly{ get; set; }
} }

View file

@ -366,6 +366,7 @@ public class CrunchyEpMeta{
public string? EpisodeNumber{ get; set; } public string? EpisodeNumber{ get; set; }
public string? EpisodeTitle{ get; set; } public string? EpisodeTitle{ get; set; }
public string? Description{ get; set; } public string? Description{ get; set; }
public string? EpisodeId{ get; set; }
public string? SeasonId{ get; set; } public string? SeasonId{ get; set; }
public string? Season{ get; set; } public string? Season{ get; set; }
public string? SeriesId{ get; set; } public string? SeriesId{ get; set; }

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views; using CRD.Views;
@ -12,6 +14,23 @@ public class AuthData{
public string Password{ get; set; } 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 class CrAuthSettings{
public string Endpoint{ get; set; } public string Endpoint{ get; set; }
public string Authorization{ get; set; } public string Authorization{ get; set; }
@ -21,6 +40,8 @@ public class CrAuthSettings{
public bool Video{ get; set; } public bool Video{ get; set; }
public bool Audio{ get; set; } public bool Audio{ get; set; }
public bool UseDefault{ get; set; } = true;
} }
public class StreamInfo{ public class StreamInfo{
@ -51,9 +72,18 @@ public class LanguageItem{
public string Language{ get; set; } public string Language{ get; set; }
} }
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{ public class EpisodeAndLanguage{
public List<CrunchyEpisode> Items{ get; set; } public List<EpisodeVariant> Variants{ get; set; } = new();
public List<LanguageItem> Langs{ get; set; }
public bool AddUnique(CrunchyEpisode item, LanguageItem lang){
if (Variants.Any(v => v.Lang.CrLocale == lang.CrLocale))
return false;
Variants.Add(new EpisodeVariant(item, lang));
return true;
}
} }
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){ public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
@ -131,6 +161,11 @@ public class StringItemWithDisplayName{
public string value{ get; set; } public string value{ get; set; }
} }
public class RefreshModeOption{
public string DisplayName{ get; set; }
public HistoryRefreshMode value{ get; set; }
}
public class WindowSettings{ public class WindowSettings{
public double Width{ get; set; } public double Width{ get; set; }
public double Height{ get; set; } public double Height{ get; set; }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using CRD.Downloader; using CRD.Downloader;
@ -140,10 +141,16 @@ public class HistoryEpisode : INotifyPropertyChanged{
} }
public async Task DownloadEpisodeDefault(){ public async Task DownloadEpisodeDefault(){
await DownloadEpisode(); await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
} }
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){ 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;
}
switch (EpisodeType){ switch (EpisodeType){
case EpisodeType.MusicVideo: case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath); await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);

View file

@ -19,7 +19,7 @@ public class HistorySeason : INotifyPropertyChanged{
public string? SeasonNum{ get; set; } public string? SeasonNum{ get; set; }
[JsonProperty("season_special_season")] [JsonProperty("season_special_season")]
public bool? SpecialSeason{ get; set; } public bool SpecialSeason{ get; set; }
[JsonProperty("season_downloaded_episodes")] [JsonProperty("season_downloaded_episodes")]
public int DownloadedEpisodes{ get; set; } public int DownloadedEpisodes{ get; set; }
@ -40,7 +40,7 @@ public class HistorySeason : INotifyPropertyChanged{
public ObservableCollection<string> HistorySeasonDubLangOverride{ get; set; } =[]; public ObservableCollection<string> HistorySeasonDubLangOverride{ get; set; } =[];
[JsonIgnore] [JsonIgnore]
public string CombinedProperty => SpecialSeason ?? false ? $"Specials {SeasonNum}" : $"Season {SeasonNum}"; public string CombinedProperty => SpecialSeason ? $"Specials {SeasonNum}" : $"Season {SeasonNum}";
[JsonIgnore] [JsonIgnore]
public bool IsExpanded{ get; set; } public bool IsExpanded{ get; set; }

View file

@ -64,16 +64,16 @@ public class HistorySeries : INotifyPropertyChanged{
public string HistorySeriesVideoQualityOverride{ get; set; } = ""; public string HistorySeriesVideoQualityOverride{ get; set; } = "";
[JsonProperty("history_series_available_soft_subs")] [JsonProperty("history_series_available_soft_subs")]
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } =[]; public List<string> HistorySeriesAvailableSoftSubs{ get; set; } = [];
[JsonProperty("history_series_available_dub_lang")] [JsonProperty("history_series_available_dub_lang")]
public List<string> HistorySeriesAvailableDubLang{ get; set; } =[]; public List<string> HistorySeriesAvailableDubLang{ get; set; } = [];
[JsonProperty("history_series_soft_subs_override")] [JsonProperty("history_series_soft_subs_override")]
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } =[]; public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } = [];
[JsonProperty("history_series_dub_lang_override")] [JsonProperty("history_series_dub_lang_override")]
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } =[]; public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } = [];
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
@ -245,9 +245,20 @@ public class HistorySeries : INotifyPropertyChanged{
} }
} }
public void UpdateNewEpisodes(){ public void UpdateNewEpisodes(){
int count = 0; NewEpisodes = EnumerateEpisodes().Count();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
}
public async Task AddNewMissingToDownloads(bool checkQueueForId = false){
var episodes = EnumerateEpisodes().ToList();
episodes.Reverse();
foreach (var ep in episodes){
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", checkQueueForId);
}
}
private IEnumerable<HistoryEpisode> EnumerateEpisodes(){
bool foundWatched = false; bool foundWatched = false;
var options = CrunchyrollManager.Instance.CrunOptions; var options = CrunchyrollManager.Instance.CrunOptions;
@ -257,64 +268,56 @@ public class HistorySeries : INotifyPropertyChanged{
!string.IsNullOrEmpty(SonarrSeriesId); !string.IsNullOrEmpty(SonarrSeriesId);
bool skipUnmonitored = options.HistorySkipUnmonitored; bool skipUnmonitored = options.HistorySkipUnmonitored;
bool countMissing = options.HistoryCountMissing; bool countMissing = options.HistoryCountMissing;
bool useSonarrCounting = options.HistoryCountSonarr; bool useSonarr = sonarrEnabled && options.HistoryCountSonarr;
for (int i = Seasons.Count - 1; i >= 0; i--){ for (int i = Seasons.Count - 1; i >= 0; i--){
var season = Seasons[i]; var season = Seasons[i];
var episodes = season.EpisodesList; var episodes = season.EpisodesList;
if (season.SpecialSeason == true){ if (season.SpecialSeason){
if (historyAddSpecials){ if (historyAddSpecials){
for (int j = episodes.Count - 1; j >= 0; j--){ for (int j = episodes.Count - 1; j >= 0; j--){
var ep = episodes[j]; var ep = episodes[j];
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){ if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored)
continue; continue;
}
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){ if (ShouldCountEpisode(ep, useSonarr, countMissing, false))
count++; yield return ep;
}
} }
} }
continue; continue;
} }
for (int j = episodes.Count - 1; j >= 0; j--){ for (int j = episodes.Count - 1; j >= 0; j--){
var ep = episodes[j]; var ep = episodes[j];
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored){ if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored)
continue; continue;
}
if (ep.SpecialEpisode){ if (ep.SpecialEpisode){
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){ if (historyAddSpecials &&
count++; ShouldCountEpisode(ep, useSonarr, countMissing, false)){
yield return ep;
} }
continue; continue;
} }
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){ if (ShouldCountEpisode(ep, useSonarr, countMissing, foundWatched)){
count++; yield return ep;
} else{ } else{
foundWatched = true; foundWatched = true;
//if not count specials break
if (!historyAddSpecials && !countMissing){ if (!historyAddSpecials && !countMissing)
break; break;
}
} }
} }
if (foundWatched && !historyAddSpecials && !countMissing){ if (foundWatched && !historyAddSpecials && !countMissing)
break; break;
}
} }
NewEpisodes = count;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
} }
private bool ShouldCountEpisode(HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){ private bool ShouldCountEpisode(HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){
@ -329,71 +332,6 @@ public class HistorySeries : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData))); 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){ public async Task<bool> FetchData(string? seasonId){
Console.WriteLine($"Fetching Data for: {SeriesTitle}"); Console.WriteLine($"Fetching Data for: {SeriesTitle}");
FetchingData = true; FetchingData = true;
@ -467,54 +405,61 @@ public class HistorySeries : INotifyPropertyChanged{
} }
public void UpdateSeriesFolderPath(){ public void UpdateSeriesFolderPath(){
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath)); // Reset state first
SeriesFolderPath = string.Empty;
SeriesFolderPathExists = false;
var season = Seasons.FirstOrDefault(s => !string.IsNullOrEmpty(s.SeasonDownloadPath));
// Series path
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){ if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
SeriesFolderPath = SeriesDownloadPath; SeriesFolderPath = SeriesDownloadPath;
SeriesFolderPathExists = true; SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
} }
if (season is{ SeasonDownloadPath: not null }){ // Season path
if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){
try{ try{
var seasonPath = season.SeasonDownloadPath; var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath);
var directoryInfo = new DirectoryInfo(seasonPath);
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){ var parentFolder = directoryInfo.Parent?.FullName;
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
if (Directory.Exists(parentFolderPath)){ if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
SeriesFolderPath = parentFolderPath; SeriesFolderPath = parentFolder;
SeriesFolderPathExists = true; SeriesFolderPathExists = true;
} PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
} }
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}"); Console.Error.WriteLine($"Error resolving season folder: {e.Message}");
} }
} else{ }
string customPath;
if (string.IsNullOrEmpty(SeriesTitle)) // Auto generated path
return; if (string.IsNullOrEmpty(SeriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle); var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle)) if (string.IsNullOrEmpty(seriesTitle)){
return; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
// Check Crunchyroll download directory string basePath =
var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath; !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath)
if (!string.IsNullOrEmpty(downloadDirPath)){ ? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath
customPath = Path.Combine(downloadDirPath, seriesTitle); : CfgManager.PathVIDEOS_DIR;
} else{
// Fallback to configured VIDEOS_DIR path
customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle);
}
// Check if custom path exists var customPath = Path.Combine(basePath, seriesTitle);
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath; if (Directory.Exists(customPath)){
SeriesFolderPathExists = true; SeriesFolderPath = customPath;
} SeriesFolderPathExists = true;
} }
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));

View file

@ -127,8 +127,7 @@ public class EpisodeHighlightTextBlock : TextBlock{
streamingService == StreamingService.Crunchyroll ? new HashSet<string>(CrunchyrollManager.Instance.CrunOptions.DlSubs) : streamingService == StreamingService.Crunchyroll ? new HashSet<string>(CrunchyrollManager.Instance.CrunOptions.DlSubs) :
new HashSet<string>(); new HashSet<string>();
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && (subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []) || subSet.Contains("all"));
subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []);
if (higlight){ if (higlight){
Foreground = Brushes.Orange; Foreground = Brushes.Orange;

View file

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

View file

@ -10,51 +10,30 @@ using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using Newtonsoft.Json; using Newtonsoft.Json;
using NuGet.Versioning;
namespace CRD.Utils.Updater; namespace CRD.Utils.Updater;
public class Updater : INotifyPropertyChanged{ public class Updater : ObservableObject{
public double progress = 0; public double Progress;
public bool failed = false; public bool Failed;
public string LatestVersion = "";
public string latestVersion = ""; public static Updater Instance{ get; } = new();
#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 string downloadUrl = "";
private readonly string tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update.zip"); private readonly string tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update", "Update.zip");
private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "ExtractedUpdate"); private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update", "ExtractedUpdate");
private readonly string changelogFilePath = Path.Combine(AppContext.BaseDirectory, "CHANGELOG.md"); 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 ApiEndpoint = "https://api.github.com/repos/Crunchy-DL/Crunchy-Downloader/releases";
private static readonly string apiEndpointLatest = apiEndpoint + "/latest"; private static readonly string ApiEndpointLatest = ApiEndpoint + "/latest";
public async Task<bool> CheckForUpdatesAsync(){ public async Task<bool> CheckForUpdatesAsync(){
if (File.Exists(tempPath)){ if (File.Exists(tempPath)){
@ -88,39 +67,49 @@ public class Updater : INotifyPropertyChanged{
Console.WriteLine($"Running on {platformName}"); 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(); HttpClientHandler handler = new HttpClientHandler();
handler.UseProxy = false; handler.UseProxy = false;
using (var client = new HttpClient(handler)){ using (var client = new HttpClient(handler)){
client.DefaultRequestHeaders.Add("User-Agent", "C# App"); client.DefaultRequestHeaders.Add("User-Agent", "C# App");
var response = await client.GetStringAsync(apiEndpointLatest); var response = await client.GetStringAsync(ApiEndpoint);
var releaseInfo = Helpers.Deserialize<GithubRelease>(response, null); var releases = Helpers.Deserialize<List<GithubRelease>>(response, null) ?? [];
if (releaseInfo == null){ bool allowPrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
Console.WriteLine($"Failed to get Update info");
var selectedRelease = releases
.FirstOrDefault(r => allowPrereleases || !r.Prerelease);
if (selectedRelease == null){
Console.WriteLine("No valid releases found.");
return false; return false;
} }
latestVersion = releaseInfo.TagName; LatestVersion = selectedRelease.TagName;
if (releaseInfo.Assets != null) var latestVersion = NuGetVersion.Parse(selectedRelease.TagName.TrimStart('v'));
foreach (var asset in releaseInfo.Assets){
string assetName = (string)asset.name; if (latestVersion > currentVersion){
if (assetName.Contains(platformName)){ Console.WriteLine($"Update available: {LatestVersion} - Current Version: {currentVersion}");
downloadUrl = asset.browser_download_url;
break; var asset = selectedRelease.Assets?
} .FirstOrDefault(a => a.IsForPlatform(platformName));
if (asset == null){
Console.WriteLine($"Failed to get Update url for {platformName}");
return false;
} }
if (string.IsNullOrEmpty(downloadUrl)){ downloadUrl = asset.BrowserDownloadUrl;
Console.WriteLine($"Failed to get Update url for {platformName}");
return false;
}
var version = Assembly.GetExecutingAssembly().GetName().Version;
var currentVersion = $"v{version?.Major}.{version?.Minor}.{version?.Build}";
if (latestVersion != currentVersion){
Console.WriteLine("Update available: " + latestVersion + " - Current Version: " + currentVersion);
_ = UpdateChangelogAsync(); _ = UpdateChangelogAsync();
return true; return true;
} }
@ -145,17 +134,17 @@ public class Updater : INotifyPropertyChanged{
existingVersion = "v1.0.0"; existingVersion = "v1.0.0";
} }
if (string.IsNullOrEmpty(latestVersion)){ if (string.IsNullOrEmpty(LatestVersion)){
latestVersion = "v1.0.0"; LatestVersion = "v1.0.0";
} }
if (existingVersion == latestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(latestVersion.TrimStart('v'))){ if (existingVersion == LatestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(LatestVersion.TrimStart('v'))){
Console.WriteLine("CHANGELOG.md is already up to date."); Console.WriteLine("CHANGELOG.md is already up to date.");
return; return;
} }
try{ 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); var releases = Helpers.Deserialize<List<GithubRelease>>(jsonResponse, null);
@ -231,7 +220,7 @@ public class Updater : INotifyPropertyChanged{
public async Task DownloadAndUpdateAsync(){ public async Task DownloadAndUpdateAsync(){
try{ try{
failed = false; Failed = false;
Helpers.EnsureDirectoriesExist(tempPath); Helpers.EnsureDirectoriesExist(tempPath);
// Download the zip file // Download the zip file
@ -249,8 +238,8 @@ public class Updater : INotifyPropertyChanged{
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length); var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0){ if (bytesRead == 0){
isMoreToRead = false; isMoreToRead = false;
progress = 100; Progress = 100;
OnPropertyChanged(nameof(progress)); OnPropertyChanged(nameof(Progress));
continue; continue;
} }
@ -258,8 +247,8 @@ public class Updater : INotifyPropertyChanged{
totalBytesRead += bytesRead; totalBytesRead += bytesRead;
if (totalBytes != -1){ if (totalBytes != -1){
progress = (double)totalBytesRead / totalBytes * 100; Progress = (double)totalBytesRead / totalBytes * 100;
OnPropertyChanged(nameof(progress)); OnPropertyChanged(nameof(Progress));
} }
} while (isMoreToRead); } while (isMoreToRead);
} }
@ -273,13 +262,13 @@ public class Updater : INotifyPropertyChanged{
ApplyUpdate(extractPath); ApplyUpdate(extractPath);
} else{ } else{
Console.Error.WriteLine("Failed to get Update"); Console.Error.WriteLine("Failed to get Update");
failed = true; Failed = true;
OnPropertyChanged(nameof(failed)); OnPropertyChanged(nameof(Failed));
} }
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine($"Failed to get Update: {e.Message}"); Console.Error.WriteLine($"Failed to get Update: {e.Message}");
failed = true; Failed = true;
OnPropertyChanged(nameof(failed)); OnPropertyChanged(nameof(Failed));
} }
} }
@ -301,8 +290,8 @@ public class Updater : INotifyPropertyChanged{
System.Diagnostics.Process.Start(chmodProcess)?.WaitForExit(); System.Diagnostics.Process.Start(chmodProcess)?.WaitForExit();
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"Error setting execute permissions: {ex.Message}"); Console.Error.WriteLine($"Error setting execute permissions: {ex.Message}");
failed = true; Failed = true;
OnPropertyChanged(nameof(failed)); OnPropertyChanged(nameof(Failed));
return; return;
} }
} }
@ -319,8 +308,8 @@ public class Updater : INotifyPropertyChanged{
Environment.Exit(0); Environment.Exit(0);
} catch (Exception ex){ } catch (Exception ex){
Console.Error.WriteLine($"Error launching updater: {ex.Message}"); Console.Error.WriteLine($"Error launching updater: {ex.Message}");
failed = true; Failed = true;
OnPropertyChanged(nameof(failed)); OnPropertyChanged(nameof(Failed));
} }
} }
@ -328,7 +317,7 @@ public class Updater : INotifyPropertyChanged{
[JsonProperty("tag_name")] [JsonProperty("tag_name")]
public string TagName{ get; set; } = string.Empty; public string TagName{ get; set; } = string.Empty;
public dynamic? Assets{ get; set; } public List<GithubAsset>? Assets{ get; set; } = [];
public string Body{ get; set; } = string.Empty; public string Body{ get; set; } = string.Empty;
[JsonProperty("published_at")] [JsonProperty("published_at")]
@ -336,4 +325,52 @@ public class Updater : INotifyPropertyChanged{
public bool Prerelease{ get; set; } 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,6 +7,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using CRD.ViewModels.Utils;
using CRD.Views.Utils; using CRD.Views.Utils;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -23,6 +26,9 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string _loginLogoutText = ""; private string _loginLogoutText = "";
[ObservableProperty]
private bool _hasMultiProfile;
[ObservableProperty] [ObservableProperty]
private string _remainingTime = ""; private string _remainingTime = "";
@ -50,8 +56,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RemainingTime = "Subscription maybe ended"; RemainingTime = "Subscription maybe ended";
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
} }
} else{ } else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}"; RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
@ -59,13 +65,18 @@ public partial class AccountPageViewModel : ViewModelBase{
} }
public void UpdatetProfile(){ public void UpdatetProfile(){
ProfileName = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username ?? CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.ProfileName ?? "???"; // Default or fetched user name
LoginLogoutText = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???" ? "Login" : "Logout"; // Default state var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
var firstEndpointProfile = firstEndpoint.Profile;
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar)); (string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription; var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
if (subscriptions != null){ if (subscriptions != null){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){ if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
@ -84,8 +95,8 @@ public partial class AccountPageViewModel : ViewModelBase{
UnknownEndDate = true; UnknownEndDate = true;
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){ if (!UnknownEndDate){
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate; _targetTime = subscriptions.NextRenewalDate;
_timer = new DispatcherTimer{ _timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1) Interval = TimeSpan.FromSeconds(1)
}; };
@ -101,8 +112,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RaisePropertyChanged(nameof(RemainingTime)); RaisePropertyChanged(nameof(RemainingTime));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
} }
} }
@ -137,6 +148,41 @@ public partial class AccountPageViewModel : ViewModelBase{
} }
} }
[RelayCommand]
public async Task OpenMultiProfileDialog(){
var multiProfile = CrunchyrollManager.Instance.CrAuthEndpoint1.MultiProfile;
var profiels = multiProfile.Profiles.Select(multiProfileProfile => new AccountProfile{
AvatarUrl = string.IsNullOrEmpty(multiProfileProfile.Avatar) ? "" : ("https://static.crunchyroll.com/assets/avatar/170x170/" + multiProfileProfile.Avatar),
ProfileName = multiProfileProfile.Username ?? multiProfileProfile.ProfileName ?? "???", CanBeSelected = multiProfileProfile is{ IsSelected: false, CanSwitch: true, IsPinProtected: false },
ProfileId = multiProfileProfile.ProfileId,
}).ToList();
var dialog = new CustomContentDialog(){
Name = "CRD Select Profile",
Title = "Select Profile",
IsPrimaryButtonEnabled = false,
CloseButtonText = "Close",
FullSizeDesired = true,
};
var viewModel = new ContentDialogMultiProfileSelectViewModel(dialog, profiels);
dialog.Content = new ContentDialogMultiProfileSelectView(){
DataContext = viewModel
};
var dialogResult = await dialog.ShowAsync();
if (dialogResult == ContentDialogResult.Primary){
var selectedProfile = viewModel.SelectedItem;
await CrunchyrollManager.Instance.CrAuthEndpoint1.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
await CrunchyrollManager.Instance.CrAuthEndpoint2.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
UpdatetProfile();
}
}
public async void LoadProfileImage(string imageUrl){ public async void LoadProfileImage(string imageUrl){
try{ try{
ProfileImage = await Helpers.LoadImage(imageUrl); ProfileImage = await Helpers.LoadImage(imageUrl);

View file

@ -32,6 +32,9 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _showUpcomingEpisodes; private bool _showUpcomingEpisodes;
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty] [ObservableProperty]
private bool _hideDubs; private bool _hideDubs;
@ -74,6 +77,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar; CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs; HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes; 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; ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0]; CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
@ -289,4 +293,14 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings(); CfgManager.WriteCrSettings();
} }
} }
partial void OnUpdateHistoryFromCalendarChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
CfgManager.WriteCrSettings();
}
} }

View file

@ -92,7 +92,6 @@ public partial class DownloadsPageViewModel : ViewModelBase{
QueueManagerIns.UpdateDownloadListItems(); QueueManagerIns.UpdateDownloadListItems();
} }
} }
public partial class DownloadItemModel : INotifyPropertyChanged{ public partial class DownloadItemModel : INotifyPropertyChanged{
@ -131,7 +130,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" ? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s"
: $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s"; : $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s";
; ;
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
DoingWhat = epMeta.Paused ? "Paused" : DoingWhat = epMeta.Paused ? "Paused" :
Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") : Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") :
@ -253,22 +252,29 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
} }
} }
public async void StartDownload(){ public Task StartDownload(){
if (!isDownloading){ QueueManager.Instance.TryStartDownload(this);
isDownloading = true; return Task.CompletedTask;
epMeta.DownloadProgress.IsDownloading = true; }
Paused = !epMeta.Paused && !isDownloading || epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
CrDownloadOptions newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions); internal async Task StartDownloadCore(){
if (isDownloading)
return;
if (epMeta.OnlySubs){ isDownloading = true;
newOptions.Novids = true; epMeta.DownloadProgress.IsDownloading = true;
newOptions.Noaudio = true;
}
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions); Paused = epMeta.Paused;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
CrDownloadOptions? newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
if (epMeta.OnlySubs){
newOptions.Novids = true;
newOptions.Noaudio = true;
} }
await CrunchyrollManager.Instance.DownloadEpisode(epMeta, epMeta.DownloadSettings ?? newOptions);
} }
[RelayCommand] [RelayCommand]

View file

@ -44,17 +44,17 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private ComboBoxItem? _selectedView; private ComboBoxItem? _selectedView;
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[]; public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private SortingListElement? _selectedSorting; private SortingListElement? _selectedSorting;
public ObservableCollection<SortingListElement> SortingList{ get; } =[]; public ObservableCollection<SortingListElement> SortingList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private FilterListElement? _selectedFilter; private FilterListElement? _selectedFilter;
public ObservableCollection<FilterListElement> FilterList{ get; } =[]; public ObservableCollection<FilterListElement> FilterList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private double _posterWidth; private double _posterWidth;
@ -116,6 +116,15 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private static string _progressText; private static string _progressText;
[ObservableProperty]
private string _searchInput;
[ObservableProperty]
private bool _isSearchOpen;
[ObservableProperty]
public bool _isSearchActiveClosed;
#region Table Mode #region Table Mode
[ObservableProperty] [ObservableProperty]
@ -126,7 +135,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
#endregion #endregion
public Vector LastScrollOffset { get; set; } = Vector.Zero; public Vector LastScrollOffset{ get; set; } = Vector.Zero;
public HistoryPageViewModel(){ public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance; ProgramManager = ProgramManager.Instance;
@ -324,11 +333,32 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series); filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
} }
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.Clear();
FilteredItems.AddRange(filteredItems); FilteredItems.AddRange(filteredItems);
} }
partial void OnSearchInputChanged(string value){
ApplyFilter();
}
partial void OnIsSearchOpenChanged(bool value){
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
}
partial void OnScaleValueChanged(double value){ partial void OnScaleValueChanged(double value){
double t = (ScaleValue - 0.5) / (1 - 0.5); double t = (ScaleValue - 0.5) / (1 - 0.5);
@ -374,6 +404,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
} }
} }
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand] [RelayCommand]
public void NavToSeries(){ public void NavToSeries(){
@ -419,7 +453,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
.SelectMany(item => item.Seasons) .SelectMany(item => item.Seasons)
.SelectMany(season => season.EpisodesList) .SelectMany(season => season.EpisodesList)
.Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile) .Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile)
.Select(historyEpisode => historyEpisode.DownloadEpisode()) .Select(historyEpisode => historyEpisode.DownloadEpisodeDefault())
); );
} }
@ -515,7 +549,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){ public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
@ -528,7 +562,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3)); MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{ } else{
foreach (var episode in missingEpisodes){ foreach (var episode in missingEpisodes){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
} }
@ -536,7 +570,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){ public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){ foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
@ -545,7 +579,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode; var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
@ -555,7 +589,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
} }
@ -567,9 +601,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){ foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){ if (historyEpisode.WasDownloaded == allDownloaded){
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId); historyEpisode.ToggleWasDownloaded();
} }
} }
seriesArgs.Season.UpdateDownloaded();
} }
seriesArgs.Series?.UpdateNewEpisodes(); seriesArgs.Series?.UpdateNewEpisodes();

View file

@ -109,6 +109,20 @@ public partial class SeriesPageViewModel : ViewModelBase{
SelectedSeries.UpdateSeriesFolderPath(); 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] [RelayCommand]
public async Task OpenFeaturedMusicDialog(){ public async Task OpenFeaturedMusicDialog(){
if (SelectedSeries.SeriesStreamingService != StreamingService.Crunchyroll || SelectedSeries.SeriesType == SeriesType.Artist){ if (SelectedSeries.SeriesStreamingService != StreamingService.Crunchyroll || SelectedSeries.SeriesType == SeriesType.Artist){
@ -213,7 +227,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){ public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
@ -226,7 +240,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3)); MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{ } else{
foreach (var episode in missingEpisodes){ foreach (var episode in missingEpisodes){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
} }
@ -236,7 +250,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode; var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
@ -246,7 +260,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
} }
@ -254,7 +268,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){ public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){ foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
@ -262,11 +276,11 @@ public partial class SeriesPageViewModel : ViewModelBase{
public void ToggleDownloadedMark(HistorySeason season){ public void ToggleDownloadedMark(HistorySeason season){
bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded); bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded);
foreach (var historyEpisode in season.EpisodesList){ foreach (var historyEpisode in season.EpisodesList.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){
if (historyEpisode.WasDownloaded == allDownloaded){ historyEpisode.ToggleWasDownloaded();
season.UpdateDownloaded(historyEpisode.EpisodeId);
}
} }
season.UpdateDownloaded();
} }
[RelayCommand] [RelayCommand]

View file

@ -15,6 +15,7 @@ using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views; using CRD.Views;
@ -168,11 +169,11 @@ public partial class UpcomingPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private static bool _sortDir; private static bool _sortDir;
public ObservableCollection<SortingListElement> SortingList{ get; } =[]; public ObservableCollection<SortingListElement> SortingList{ get; } = [];
public ObservableCollection<SeasonViewModel> Seasons{ get; set; } =[]; public ObservableCollection<SeasonViewModel> Seasons{ get; set; } = [];
public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } =[]; public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } = [];
private SeasonViewModel currentSelection; private SeasonViewModel currentSelection;
@ -213,8 +214,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){ if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID); var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){ if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[])); anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ?? []));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[])); anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ?? []));
} }
} }
} }
@ -240,8 +241,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){ if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID); var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){ if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[])); anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ?? []));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[])); anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ?? []));
} }
} }
} }
@ -252,7 +253,6 @@ public partial class UpcomingPageViewModel : ViewModelBase{
} }
[RelayCommand] [RelayCommand]
public void OpenTrailer(AnilistSeries series){ public void OpenTrailer(AnilistSeries series){
if (series.Trailer != null){ if (series.Trailer != null){
@ -333,7 +333,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
hasNext = pageNode?.PageInfo?.HasNextPage ?? false; hasNext = pageNode?.PageInfo?.HasNextPage ?? false;
page++; page++;
} while (hasNext || page <= maxPage); } while (hasNext && page <= maxPage);
var list = allMedia.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external => var list = allMedia.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external =>
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList(); string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList();
@ -462,45 +462,27 @@ public partial class UpcomingPageViewModel : ViewModelBase{
} }
private ObservableCollection<SeasonViewModel> GetTargetSeasonsAndYears(){ private ObservableCollection<SeasonViewModel> GetTargetSeasonsAndYears(){
DateTime now = DateTime.Now; var seasons = new[]{ "WINTER", "SPRING", "SUMMER", "FALL" };
int currentMonth = now.Month;
var now = DateTime.Now;
int currentYear = now.Year; int currentYear = now.Year;
int currentSeasonIndex = (now.Month - 1) / 3;
string currentSeason; var result = new ObservableCollection<SeasonViewModel>();
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++){ for (int i = -2; i <= 1; i++){
int targetIndex = (currentSeasonIndex + i + 4) % 4; int rawIndex = currentSeasonIndex + i;
string targetSeason = seasons[targetIndex];
int targetYear = currentYear;
int yearOffset = (int)Math.Floor(rawIndex / 4.0);
int seasonIndex = (rawIndex % 4 + 4) % 4;
if (i < 0 && targetIndex == 3){ result.Add(new SeasonViewModel{
targetYear--; Season = seasons[seasonIndex],
} else if (i > 0 && targetIndex == 0){ Year = currentYear + yearOffset
targetYear++; });
}
targetSeasons.Add(new SeasonViewModel(){ Season = targetSeason, Year = targetYear });
} }
return targetSeasons; return result;
} }
public void SelectionChangedOfSeries(AnilistSeries? value){ public void SelectionChangedOfSeries(AnilistSeries? value){
@ -584,7 +566,6 @@ public partial class UpcomingPageViewModel : ViewModelBase{
} }
private void FilterItems(){ private void FilterItems(){
List<AnilistSeries> filteredList; List<AnilistSeries> filteredList;
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){ if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){

View file

@ -8,6 +8,7 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
@ -16,6 +17,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CRD.Downloader; using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files;
using CRD.Utils.Updater; using CRD.Utils.Updater;
using Markdig; using Markdig;
using Markdig.Syntax; using Markdig.Syntax;
@ -26,35 +28,50 @@ namespace CRD.ViewModels;
public partial class UpdateViewModel : ViewModelBase{ public partial class UpdateViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _updateAvailable; private bool updating;
[ObservableProperty] [ObservableProperty]
private bool _updating; private double progress;
[ObservableProperty] [ObservableProperty]
private double _progress; private bool failed;
[ObservableProperty] [ObservableProperty]
private bool _failed; private string currentVersion;
private AccountPageViewModel accountPageViewModel;
[ObservableProperty] [ObservableProperty]
private string _currentVersion; private bool ghUpdatePrereleases;
public ObservableCollection<Control> ChangelogBlocks{ get; } = new(); public ObservableCollection<Control> ChangelogBlocks{ get; } = new();
public ProgramManager ProgramManager { get; }
public UpdateViewModel(){ public UpdateViewModel(){
var version = Assembly.GetExecutingAssembly().GetName().Version; var version = Assembly
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}"; .GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0];
CurrentVersion = $"v{version}";
ProgramManager = ProgramManager.Instance;
LoadChangelog(); LoadChangelog();
UpdateAvailable = ProgramManager.Instance.UpdateAvailable; GhUpdatePrereleases = CrunchyrollManager.Instance.CrunOptions.GhUpdatePrereleases;
Updater.Instance.PropertyChanged += Progress_PropertyChanged; 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] [RelayCommand]
public void StartUpdate(){ public void StartUpdate(){
Updating = true; Updating = true;
@ -63,10 +80,10 @@ public partial class UpdateViewModel : ViewModelBase{
} }
private void Progress_PropertyChanged(object? sender, PropertyChangedEventArgs e){ private void Progress_PropertyChanged(object? sender, PropertyChangedEventArgs e){
if (e.PropertyName == nameof(Updater.Instance.progress)){ if (e.PropertyName == nameof(Updater.Instance.Progress)){
Progress = Updater.Instance.progress; Progress = Updater.Instance.Progress;
} else if (e.PropertyName == nameof(Updater.Instance.failed)){ } else if (e.PropertyName == nameof(Updater.Instance.Failed)){
Failed = Updater.Instance.failed; Failed = Updater.Instance.Failed;
ProgramManager.Instance.NavigationLock = !Failed; ProgramManager.Instance.NavigationLock = !Failed;
} }
} }

View file

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

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using FluentAvalonia.UI.Controls;
namespace CRD.ViewModels.Utils;
public partial class ContentDialogMultiProfileSelectViewModel: ViewModelBase{
private readonly CustomContentDialog dialog;
[ObservableProperty]
private AccountProfile _selectedItem;
[ObservableProperty]
private ObservableCollection<AccountProfile> _profileList = [];
public ContentDialogMultiProfileSelectViewModel(CustomContentDialog contentDialog, List<AccountProfile> profiles){
ArgumentNullException.ThrowIfNull(contentDialog);
dialog = contentDialog;
dialog.Closed += DialogOnClosed;
dialog.PrimaryButtonClick += SaveButton;
try{
_ = LoadProfiles(profiles);
} catch (Exception e){
Console.WriteLine(e);
}
}
private async Task LoadProfiles(List<AccountProfile> profiles){
foreach (var accountProfile in profiles){
accountProfile.ProfileImage = await Helpers.LoadImage(accountProfile.AvatarUrl);
ProfileList.Add(accountProfile);
}
}
partial void OnSelectedItemChanged(AccountProfile value){
dialog.Hide(ContentDialogResult.Primary);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
dialog.PrimaryButtonClick -= SaveButton;
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
dialog.Closed -= DialogOnClosed;
}
}

View file

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@ -18,104 +19,138 @@ using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Http;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using FluentAvalonia.Styling; using FluentAvalonia.Styling;
namespace CRD.ViewModels.Utils; namespace CRD.ViewModels.Utils;
// ReSharper disable InconsistentNaming
public partial class GeneralSettingsViewModel : ViewModelBase{ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string _currentVersion; private string currentVersion;
[ObservableProperty] [ObservableProperty]
private bool _downloadToTempFolder; private bool downloadToTempFolder;
[ObservableProperty] [ObservableProperty]
private bool _history; private bool history;
[ObservableProperty] [ObservableProperty]
private bool _historyCountMissing; private bool historyCountMissing;
[ObservableProperty] [ObservableProperty]
private bool _historyIncludeCrArtists; private bool historyIncludeCrArtists;
[ObservableProperty] [ObservableProperty]
private bool _historyAddSpecials; private bool historyAddSpecials;
[ObservableProperty] [ObservableProperty]
private bool _historySkipUnmonitored; private bool historySkipUnmonitored;
[ObservableProperty] [ObservableProperty]
private bool _historyCountSonarr; private bool historyCountSonarr;
[ObservableProperty] [ObservableProperty]
private double? _simultaneousDownloads; private double? historyAutoRefreshIntervalMinutes;
[ObservableProperty] [ObservableProperty]
private double? _simultaneousProcessingJobs; private HistoryRefreshMode historyAutoRefreshMode;
[ObservableProperty] [ObservableProperty]
private bool _downloadMethodeNew; private string historyAutoRefreshModeHint;
[ObservableProperty] [ObservableProperty]
private bool _downloadAllowEarlyStart; private string historyAutoRefreshLastRunTime;
public ObservableCollection<RefreshModeOption> HistoryAutoRefreshModes{ get; } = new(){
new RefreshModeOption{ DisplayName = "Default All", value = HistoryRefreshMode.DefaultAll },
new RefreshModeOption{ DisplayName = "Default Active", value = HistoryRefreshMode.DefaultActive },
new RefreshModeOption{ DisplayName = "Fast New Releases", value = HistoryRefreshMode.FastNewReleases },
};
[ObservableProperty] [ObservableProperty]
private double? _downloadSpeed; private double? simultaneousDownloads;
[ObservableProperty] [ObservableProperty]
private bool _downloadSpeedInBits; private double? simultaneousProcessingJobs;
[ObservableProperty] [ObservableProperty]
private double? _retryAttempts; private bool downloadMethodeNew;
[ObservableProperty] [ObservableProperty]
private double? _retryDelay; private bool downloadOnlyWithAllSelectedDubSub;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem _selectedHistoryLang; private bool downloadAllowEarlyStart;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem? _currentAppTheme; private double? downloadSpeed;
[ObservableProperty] [ObservableProperty]
private bool _useCustomAccent; private bool downloadSpeedInBits;
[ObservableProperty] [ObservableProperty]
private string _backgroundImagePath; private double? retryAttempts;
[ObservableProperty] [ObservableProperty]
private double? _backgroundImageOpacity; private double? retryDelay;
[ObservableProperty] [ObservableProperty]
private double? _backgroundImageBlurRadius; private bool trayIconEnabled;
[ObservableProperty] [ObservableProperty]
private Color _listBoxColor; private bool startMinimizedToTray;
[ObservableProperty] [ObservableProperty]
private Color _customAccentColor = Colors.SlateBlue; private bool minimizeToTray;
[ObservableProperty] [ObservableProperty]
private string _sonarrHost = "localhost"; private bool minimizeToTrayOnClose;
[ObservableProperty] [ObservableProperty]
private string _sonarrPort = "8989"; private ComboBoxItem selectedHistoryLang;
[ObservableProperty] [ObservableProperty]
private string _sonarrApiKey = ""; private ComboBoxItem? currentAppTheme;
[ObservableProperty] [ObservableProperty]
private bool _sonarrUseSsl = false; private bool useCustomAccent;
[ObservableProperty] [ObservableProperty]
private bool _sonarrUseSonarrNumbering = false; private string backgroundImagePath;
[ObservableProperty] [ObservableProperty]
private bool _logMode = false; private double? backgroundImageOpacity;
[ObservableProperty]
private double? backgroundImageBlurRadius;
[ObservableProperty]
private Color listBoxColor;
[ObservableProperty]
private Color customAccentColor = Colors.SlateBlue;
[ObservableProperty]
private string sonarrHost = "localhost";
[ObservableProperty]
private string sonarrPort = "8989";
[ObservableProperty]
private string sonarrApiKey = "";
[ObservableProperty]
private bool sonarrUseSsl;
[ObservableProperty]
private bool sonarrUseSonarrNumbering;
[ObservableProperty]
private bool logMode;
public ObservableCollection<Color> PredefinedColors{ get; } = new(){ public ObservableCollection<Color> PredefinedColors{ get; } = new(){
Color.FromRgb(255, 185, 0), Color.FromRgb(255, 185, 0),
@ -170,84 +205,105 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}; };
public ObservableCollection<ComboBoxItem> AppThemes{ get; } = new(){ public ObservableCollection<ComboBoxItem> AppThemes{ get; } = new(){
new ComboBoxItem(){ Content = "System" }, new ComboBoxItem{ Content = "System" },
new ComboBoxItem(){ Content = "Light" }, new ComboBoxItem{ Content = "Light" },
new ComboBoxItem(){ Content = "Dark" }, new ComboBoxItem{ Content = "Dark" },
}; };
public ObservableCollection<ComboBoxItem> HistoryLangList{ get; } = new(){ public ObservableCollection<ComboBoxItem> HistoryLangList{ get; } = new(){
new ComboBoxItem(){ Content = "default" }, new ComboBoxItem{ Content = "default" },
new ComboBoxItem(){ Content = "de-DE" }, new ComboBoxItem{ Content = "de-DE" },
new ComboBoxItem(){ Content = "en-US" }, new ComboBoxItem{ Content = "en-US" },
new ComboBoxItem(){ Content = "es-419" }, new ComboBoxItem{ Content = "es-419" },
new ComboBoxItem(){ Content = "es-ES" }, new ComboBoxItem{ Content = "es-ES" },
new ComboBoxItem(){ Content = "fr-FR" }, new ComboBoxItem{ Content = "fr-FR" },
new ComboBoxItem(){ Content = "it-IT" }, new ComboBoxItem{ Content = "it-IT" },
new ComboBoxItem(){ Content = "pt-BR" }, new ComboBoxItem{ Content = "pt-BR" },
new ComboBoxItem(){ Content = "pt-PT" }, new ComboBoxItem{ Content = "pt-PT" },
new ComboBoxItem(){ Content = "ru-RU" }, new ComboBoxItem{ Content = "ru-RU" },
new ComboBoxItem(){ Content = "hi-IN" }, new ComboBoxItem{ Content = "hi-IN" },
new ComboBoxItem(){ Content = "ar-SA" }, new ComboBoxItem{ Content = "ar-SA" },
}; };
[ObservableProperty] [ObservableProperty]
private string _downloadDirPath; private string downloadDirPath;
[ObservableProperty] [ObservableProperty]
private bool _proxyEnabled; private bool proxyEnabled;
[ObservableProperty] [ObservableProperty]
private bool _proxySocks; private bool proxySocks;
[ObservableProperty] [ObservableProperty]
private string _proxyHost; private string proxyHost;
[ObservableProperty] [ObservableProperty]
private double? _proxyPort; private double? proxyPort;
[ObservableProperty] [ObservableProperty]
private string _proxyUsername; private string proxyUsername;
[ObservableProperty] [ObservableProperty]
private string _proxyPassword; private string proxyPassword;
[ObservableProperty] [ObservableProperty]
private string _flareSolverrHost = "localhost"; private string flareSolverrHost = "localhost";
[ObservableProperty] [ObservableProperty]
private string _flareSolverrPort = "8191"; private string flareSolverrPort = "8191";
[ObservableProperty] [ObservableProperty]
private bool _flareSolverrUseSsl = false; private bool flareSolverrUseSsl;
[ObservableProperty] [ObservableProperty]
private bool _useFlareSolverr = false; private bool useFlareSolverr;
[ObservableProperty] [ObservableProperty]
private string _tempDownloadDirPath; private string mitmFlareSolverrHost = "localhost";
[ObservableProperty] [ObservableProperty]
private bool _downloadFinishedPlaySound; private string mitmFlareSolverrPort = "8080";
[ObservableProperty] [ObservableProperty]
private string _downloadFinishedSoundPath; private bool mitmFlareSolverrUseSsl;
[ObservableProperty] [ObservableProperty]
private string _currentIp = ""; private bool useMitmFlareSolverr;
private readonly FluentAvaloniaTheme _faTheme; [ObservableProperty]
private string tempDownloadDirPath;
[ObservableProperty]
private bool downloadFinishedPlaySound;
[ObservableProperty]
private string downloadFinishedSoundPath;
[ObservableProperty]
private bool downloadFinishedExecute;
[ObservableProperty]
private string downloadFinishedExecutePath;
[ObservableProperty]
private string currentIp = "";
private readonly FluentAvaloniaTheme faTheme;
private bool settingsLoaded; private bool settingsLoaded;
private IStorageProvider _storageProvider; private IStorageProvider? storageProvider;
public GeneralSettingsViewModel(){ public GeneralSettingsViewModel(){
_storageProvider = ProgramManager.Instance.StorageProvider ?? throw new ArgumentNullException(nameof(ProgramManager.Instance.StorageProvider)); storageProvider = ProgramManager.Instance.StorageProvider ?? throw new ArgumentNullException(nameof(ProgramManager.Instance.StorageProvider));
var version = Assembly.GetExecutingAssembly().GetName().Version; var version = Assembly
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}"; .GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion.Split('+')[0];
CurrentVersion = $"v{version}";
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme ??[]; faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme ??[];
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){ if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor); CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
@ -264,6 +320,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty; DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound; DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
DownloadFinishedExecute = options.DownloadFinishedExecute;
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath; DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath; TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -289,6 +348,15 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
FlareSolverrPort = propsFlareSolverr.Port + ""; 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; ProxyEnabled = options.ProxyEnabled;
ProxySocks = options.ProxySocks; ProxySocks = options.ProxySocks;
ProxyHost = options.ProxyHost ?? ""; ProxyHost = options.ProxyHost ?? "";
@ -300,10 +368,14 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
HistoryAddSpecials = options.HistoryAddSpecials; HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored; HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr; 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; DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits; DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew; DownloadMethodeNew = options.DownloadMethodeNew;
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart; DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10); RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30); RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
DownloadToTempFolder = options.DownloadToTempFolder; DownloadToTempFolder = options.DownloadToTempFolder;
@ -311,6 +383,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs; SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode; 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; ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
CurrentAppTheme = theme ?? AppThemes[0]; CurrentAppTheme = theme ?? AppThemes[0];
@ -320,6 +397,16 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
History = options.History; 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; settingsLoaded = true;
} }
@ -332,8 +419,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound; settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
settings.DownloadFinishedExecute = DownloadFinishedExecute;
settings.DownloadMethodeNew = DownloadMethodeNew; settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart; settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40); settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1); settings.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
@ -347,6 +437,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists; settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored; settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr; 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.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits; settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10); settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
@ -367,8 +459,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.Theme = CurrentAppTheme?.Content + ""; settings.Theme = CurrentAppTheme?.Content + "";
if (_faTheme.CustomAccentColor != (Application.Current?.PlatformSettings?.GetColorValues().AccentColor1)){ if (faTheme.CustomAccentColor != (Application.Current?.PlatformSettings?.GetColorValues().AccentColor1)){
settings.AccentColor = _faTheme.CustomAccentColor.ToString(); settings.AccentColor = faTheme.CustomAccentColor.ToString();
} else{ } else{
settings.AccentColor = string.Empty; settings.AccentColor = string.Empty;
} }
@ -403,7 +495,26 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
propsFlareSolverr.Port = 8989; 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.FlareSolverrProperties = propsFlareSolverr;
settings.FlareSolverrMitmProperties = propsMitmFlareSolverr;
settings.TrayIconEnabled = TrayIconEnabled;
settings.StartMinimizedToTray = StartMinimizedToTray;
settings.MinimizeToTray = MinimizeToTray;
settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose;
settings.LogMode = LogMode; settings.LogMode = LogMode;
@ -429,7 +540,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path; DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
defaultPath: CfgManager.PathVIDEOS_DIR defaultPath: CfgManager.PathVIDEOS_DIR
); );
} }
@ -441,18 +552,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path; TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
defaultPath: CfgManager.PathTEMP_DIR defaultPath: CfgManager.PathTEMP_DIR
); );
} }
private async Task OpenFolderDialogAsyncInternal(Action<string> pathSetter, Func<string> pathGetter, string defaultPath){ 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."); Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
throw new InvalidOperationException("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" Title = "Select Folder"
}); });
@ -490,7 +601,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImagePath = path; BackgroundImagePath = path;
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius); Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
defaultPath: string.Empty defaultPath: string.Empty
); );
} }
@ -519,7 +630,35 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path; DownloadFinishedSoundPath = path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
defaultPath: string.Empty
);
}
#endregion
#region Download Finished Execute File
[RelayCommand]
public void ClearFinishedExectuePath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
DownloadFinishedExecutePath = string.Empty;
}
[RelayCommand]
public async Task OpenFileDialogAsyncInternalFinishedExecute(){
await OpenFileDialogAsyncInternal(
title: "Select File",
fileTypes: new List<FilePickerFileType>{
new("All Files"){
Patterns = new[]{ "*.*" }
}
},
pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
DownloadFinishedExecutePath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
defaultPath: string.Empty defaultPath: string.Empty
); );
} }
@ -532,12 +671,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
Action<string> pathSetter, Action<string> pathSetter,
Func<string> pathGetter, Func<string> pathGetter,
string defaultPath){ string defaultPath){
if (_storageProvider == null){ if (storageProvider == null){
Console.Error.WriteLine("StorageProvider must be set before using the dialog."); Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
throw new InvalidOperationException("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, Title = title,
FileTypeFilter = fileTypes, FileTypeFilter = fileTypes,
AllowMultiple = false AllowMultiple = false
@ -556,13 +695,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){ partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
if (value?.Content?.ToString() == "System"){ if (value?.Content?.ToString() == "System"){
_faTheme.PreferSystemTheme = true; faTheme.PreferSystemTheme = true;
} else if (value?.Content?.ToString() == "Dark"){ } else if (value?.Content?.ToString() == "Dark"){
_faTheme.PreferSystemTheme = false; faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark; Application.Current?.RequestedThemeVariant = ThemeVariant.Dark;
} else{ } else{
_faTheme.PreferSystemTheme = false; faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light; Application.Current?.RequestedThemeVariant = ThemeVariant.Light;
} }
UpdateSettings(); UpdateSettings();
@ -570,7 +709,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
partial void OnUseCustomAccentChanged(bool value){ partial void OnUseCustomAccentChanged(bool value){
if (value){ if (value){
if (_faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){ if (faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){
CustomAccentColor = (Color)curColor; CustomAccentColor = (Color)curColor;
ListBoxColor = CustomAccentColor; ListBoxColor = CustomAccentColor;
@ -601,9 +740,14 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
private void UpdateAppAccentColor(Color? color){ private void UpdateAppAccentColor(Color? color){
_faTheme.CustomAccentColor = color; faTheme.CustomAccentColor = color;
UpdateSettings(); UpdateSettings();
} }
partial void OnTrayIconEnabledChanged(bool value){
((App)Application.Current!).SetTrayIconVisible(value);
UpdateSettings();
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e){ protected override void OnPropertyChanged(PropertyChangedEventArgs e){
base.OnPropertyChanged(e); base.OnPropertyChanged(e);
@ -613,12 +757,23 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(ListBoxColor) or nameof(ListBoxColor)
or nameof(CurrentAppTheme) or nameof(CurrentAppTheme)
or nameof(UseCustomAccent) or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)){ or nameof(LogMode)){
return; return;
} }
UpdateSettings(); 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 (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){ if (CrunchyrollManager.Instance.CrunOptions.History){
if (File.Exists(CfgManager.PathCrHistory)){ if (File.Exists(CfgManager.PathCrHistory)){
@ -658,7 +813,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
[RelayCommand] [RelayCommand]
public async void CheckIp(){ public async Task CheckIp(){
var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false)); var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false));
Console.Error.WriteLine("Your IP: " + result.ResponseContent); Console.Error.WriteLine("Your IP: " + result.ResponseContent);
if (result.IsOk){ if (result.IsOk){

View file

@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:CRD.ViewModels" xmlns:vm="clr-namespace:CRD.ViewModels"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
x:DataType="vm:AccountPageViewModel" x:DataType="vm:AccountPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.AccountPageView"> x:Class="CRD.Views.AccountPageView">
@ -13,13 +14,33 @@
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center"> <StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
<!-- Profile Image --> <Grid Width="170" Height="170" Margin="20"
<Image Width="170" Height="170" Margin="20" HorizontalAlignment="Center"
Source="{Binding ProfileImage}"> VerticalAlignment="Top">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170" /> <!-- Profile Image -->
</Image.Clip> <Image Source="{Binding ProfileImage}">
</Image> <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 --> <!-- Profile Name -->
<TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" /> <TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" />

View file

@ -99,6 +99,10 @@
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}" <CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
Content="Show Upcoming episodes" Margin="5 5 0 0"> Content="Show Upcoming episodes" Margin="5 5 0 0">
</CheckBox> </CheckBox>
<CheckBox IsChecked="{Binding UpdateHistoryFromCalendar}"
Content="Update History from Calendar" Margin="5 5 0 0">
</CheckBox>
</StackPanel> </StackPanel>
</controls:SettingsExpander.Footer> </controls:SettingsExpander.Footer>
@ -185,7 +189,8 @@
<Grid HorizontalAlignment="Center"> <Grid HorizontalAlignment="Center">
<Grid> <Grid>
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" /> <Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" />
<Image HorizontalAlignment="Center" MaxHeight="150" Source="{Binding ImageBitmap}" /> <Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" IsVisible="{Binding AnilistEpisode}" MaxHeight="150" Source="{Binding ImageBitmap}" />
</Grid> </Grid>

View file

@ -13,7 +13,7 @@
Unloaded="OnUnloaded" Unloaded="OnUnloaded"
Loaded="Control_OnLoaded"> Loaded="Control_OnLoaded">
<UserControl.Resources> <UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" /> <ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
<ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" /> <ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" />
<ui:UiListToStringConverter x:Key="UiListToStringConverter" /> <ui:UiListToStringConverter x:Key="UiListToStringConverter" />
@ -70,6 +70,48 @@
</StackPanel> </StackPanel>
</ToggleButton> </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" /> <Rectangle Width="1" Height="50" Fill="Gray" Margin="10,0" />
<StackPanel Margin="10,0"> <StackPanel Margin="10,0">
@ -115,6 +157,7 @@
<!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> --> <!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> -->
</StackPanel> </StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal"> <StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100" <Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100"

View file

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

View file

@ -5,6 +5,7 @@ using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using CRD.Downloader; using CRD.Downloader;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
@ -47,6 +48,8 @@ public partial class MainWindow : AppWindow{
private object selectedNavVieItem; private object selectedNavVieItem;
private ToastNotification? toast;
private const int TitleBarHeightAdjustment = 31; private const int TitleBarHeightAdjustment = 31;
private PixelPoint _restorePosition; private PixelPoint _restorePosition;
@ -70,6 +73,7 @@ public partial class MainWindow : AppWindow{
PositionChanged += OnPositionChanged; PositionChanged += OnPositionChanged;
SizeChanged += OnSizeChanged; SizeChanged += OnSizeChanged;
toast = this.FindControl<ToastNotification>("Toast");
//select first element as default //select first element as default
var nv = this.FindControl<NavigationView>("NavView"); var nv = this.FindControl<NavigationView>("NavView");
@ -150,55 +154,48 @@ public partial class MainWindow : AppWindow{
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){ public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
var toastControl = this.FindControl<ToastNotification>("Toast"); Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds));
toastControl?.Show(message, type, durationInSeconds);
} }
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){ private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
if (sender is NavigationView navView){ if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){
var selectedItem = navView.SelectedItem as NavigationViewItem; switch (selectedItem.Tag){
if (selectedItem != null){ case "DownloadQueue":
switch (selectedItem.Tag){ navView.Content = Activator.CreateInstance<DownloadsPageViewModel>();
case "DownloadQueue": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel)); break;
selectedNavVieItem = selectedItem; case "AddDownload":
break; navView.Content = Activator.CreateInstance<AddDownloadPageViewModel>();
case "AddDownload": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel)); break;
selectedNavVieItem = selectedItem; case "Calendar":
break; navView.Content = Activator.CreateInstance<CalendarPageViewModel>();
case "Calendar": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel)); break;
selectedNavVieItem = selectedItem; case "History":
break; navView.Content = Activator.CreateInstance<HistoryPageViewModel>();
case "History": navigationStack.Clear();
navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel)); navigationStack.Push(navView.Content);
navigationStack.Clear(); selectedNavVieItem = selectedItem;
navigationStack.Push(navView.Content); break;
selectedNavVieItem = selectedItem; case "Seasons":
break; navView.Content = Activator.CreateInstance<UpcomingPageViewModel>();
case "Seasons": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel)); break;
selectedNavVieItem = selectedItem; case "Account":
break; navView.Content = Activator.CreateInstance<AccountPageViewModel>();
case "Account": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel)); break;
selectedNavVieItem = selectedItem; case "Settings":
break; var viewModel = Activator.CreateInstance<SettingsPageViewModel>();
case "Settings": navView.Content = viewModel;
var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel)); selectedNavVieItem = selectedItem;
navView.Content = viewModel; break;
selectedNavVieItem = selectedItem; case "Update":
break; navView.Content = Activator.CreateInstance<UpdateViewModel>();
case "Update": selectedNavVieItem = selectedItem;
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel)); break;
selectedNavVieItem = selectedItem;
break;
default:
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
break;
}
} }
} }
} }
@ -209,7 +206,7 @@ public partial class MainWindow : AppWindow{
if (settings != null){ if (settings != null){
var screens = Screens.All; var screens = Screens.All;
if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){ if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){
var screen = screens[settings.ScreenIndex]; // var screen = screens[settings.ScreenIndex];
// Restore the position first // Restore the position first
Position = new PixelPoint(settings.PosX, settings.PosY); Position = new PixelPoint(settings.PosX, settings.PosY);

View file

@ -161,6 +161,13 @@
<Button Margin="0 0 5 10" FontStyle="Italic" <Button Margin="0 0 5 10" FontStyle="Italic"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding OpenFolderDialogAsync}"> Command="{Binding OpenFolderDialogAsync}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding ClearFolderPathCommand}" />
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock Text="{Binding SelectedSeries.SeriesDownloadPath, <TextBlock Text="{Binding SelectedSeries.SeriesDownloadPath,
Converter={StaticResource EmptyToDefault}, Converter={StaticResource EmptyToDefault},
@ -646,6 +653,13 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).OpenFolderDialogAsync}" Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).OpenFolderDialogAsync}"
CommandParameter="{Binding .}"> CommandParameter="{Binding .}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).ClearFolderPathCommand}"
CommandParameter="{Binding .}"/>
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock Text="{Binding SeasonDownloadPath, <TextBlock Text="{Binding SeasonDownloadPath,
Converter={StaticResource EmptyToDefault}, Converter={StaticResource EmptyToDefault},

View file

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

View file

@ -47,10 +47,21 @@
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal"> <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" <Button Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding StartUpdate}" Command="{Binding StartUpdate}"
IsEnabled="{Binding UpdateAvailable}"> IsEnabled="{Binding ProgramManager.UpdateAvailable}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center"> <StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<controls:SymbolIcon Symbol="Download" FontSize="32" /> <controls:SymbolIcon Symbol="Download" FontSize="32" />
<TextBlock Text="Update" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock> <TextBlock Text="Update" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock>

View file

@ -0,0 +1,78 @@
<ui:CustomContentDialog xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CRD.ViewModels.Utils"
xmlns:structs="clr-namespace:CRD.Utils.Structs"
xmlns:ui="clr-namespace:CRD.Utils.UI"
x:DataType="vm:ContentDialogMultiProfileSelectViewModel"
x:Class="CRD.Views.Utils.ContentDialogMultiProfileSelectView">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="3"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<ListBox ItemsSource="{Binding ProfileList}"
SelectedItem="{Binding SelectedItem}"
Background="Transparent"
BorderThickness="0">
<!-- Horizontal layout -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem" x:DataType="structs:AccountProfile">
<Setter Property="IsEnabled">
<Setter.Value>
<Binding Path="CanBeSelected"/>
</Setter.Value>
</Setter>
</Style>
<Style Selector="ListBoxItem:disabled">
<Setter Property="Opacity" Value="0.4"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate >
<DataTemplate>
<StackPanel Width="220"
Margin="0,0,12,0"
IsEnabled="{Binding CanBeSelected}">
<Grid Width="170"
Height="170"
Margin="20"
HorizontalAlignment="Center">
<Image Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170"/>
</Image.Clip>
</Image>
</Grid>
<TextBlock Text="{Binding ProfileName}"
HorizontalAlignment="Center"
FontSize="20"
Margin="10"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</ui:CustomContentDialog>

View file

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

View file

@ -63,6 +63,43 @@
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </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>
<controls:SettingsExpander Header="Download Settings" <controls:SettingsExpander Header="Download Settings"
@ -82,6 +119,12 @@
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </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" <controls:SettingsExpanderItem Content="Max Download Speed"
Description="Download in Kb/s - 0 is full speed"> Description="Download in Kb/s - 0 is full speed">
<controls:SettingsExpanderItem.Footer> <controls:SettingsExpanderItem.Footer>
@ -242,50 +285,49 @@
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox> <CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel> </StackPanel>
<!-- <Grid HorizontalAlignment="Right" Margin="0 5 0 0"> --> </StackPanel>
<!-- <Grid.ColumnDefinitions> -->
<!-- <ColumnDefinition Width="Auto" /> --> </controls:SettingsExpanderItem.Footer>
<!-- <ColumnDefinition Width="150" /> --> </controls:SettingsExpanderItem>
<!-- </Grid.ColumnDefinitions> -->
<!-- --> <controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
<!-- <Grid.RowDefinitions> --> <controls:SettingsExpanderItem.Footer>
<!-- <RowDefinition Height="Auto" /> --> <StackPanel Spacing="10">
<!-- <RowDefinition Height="Auto" /> --> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<!-- </Grid.RowDefinitions> --> <TextBlock IsVisible="{Binding DownloadFinishedExecute}"
<!-- --> Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
<!-- <TextBlock Text="Opacity" --> FontSize="15"
<!-- FontSize="15" --> Opacity="0.8"
<!-- Opacity="0.8" --> TextWrapping="NoWrap"
<!-- VerticalAlignment="Center" --> TextAlignment="Center"
<!-- HorizontalAlignment="Right" --> VerticalAlignment="Center" />
<!-- Margin="0 0 5 10" -->
<!-- Grid.Row="0" Grid.Column="0" /> --> <Button IsVisible="{Binding DownloadFinishedExecute}"
<!-- <controls:NumberBox Minimum="0" Maximum="1" --> Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
<!-- SmallChange="0.05" --> VerticalAlignment="Center"
<!-- LargeChange="0.1" --> FontStyle="Italic">
<!-- SimpleNumberFormat="F2" --> <ToolTip.Tip>
<!-- Value="{Binding BackgroundImageOpacity}" --> <TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
<!-- SpinButtonPlacementMode="Inline" --> </ToolTip.Tip>
<!-- HorizontalAlignment="Stretch" --> <StackPanel Orientation="Horizontal" Spacing="5">
<!-- Margin="0 0 0 10" --> <controls:SymbolIcon Symbol="Folder" FontSize="18" />
<!-- Grid.Row="0" Grid.Column="1" /> --> </StackPanel>
<!-- --> </Button>
<!-- <TextBlock Text="Blur Radius" -->
<!-- FontSize="15" --> <Button IsVisible="{Binding DownloadFinishedExecute}"
<!-- Opacity="0.8" --> Command="{Binding ClearFinishedExectuePath}"
<!-- VerticalAlignment="Center" --> VerticalAlignment="Center"
<!-- HorizontalAlignment="Right" --> FontStyle="Italic">
<!-- Margin="0 0 5 0" --> <ToolTip.Tip>
<!-- Grid.Row="1" Grid.Column="0" /> --> <TextBlock Text="Clear selected file" FontSize="15" />
<!-- <controls:NumberBox Minimum="0" Maximum="40" --> </ToolTip.Tip>
<!-- SmallChange="1" --> <StackPanel Orientation="Horizontal" Spacing="5">
<!-- LargeChange="5" --> <controls:SymbolIcon Symbol="Clear" FontSize="18" />
<!-- SimpleNumberFormat="F0" --> </StackPanel>
<!-- Value="{Binding BackgroundImageBlurRadius}" --> </Button>
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" --> <CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
<!-- Grid.Row="1" Grid.Column="1" /> --> </StackPanel>
<!-- </Grid> -->
</StackPanel> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
@ -423,6 +465,65 @@
</controls:SettingsExpanderItem> </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>
<controls:SettingsExpander Header="App Appearance" <controls:SettingsExpander Header="App Appearance"

33
Dockerfile.webtop Normal file
View file

@ -0,0 +1,33 @@
ARG WEBTOP_TAG=ubuntu-xfce
FROM lscr.io/linuxserver/webtop:${WEBTOP_TAG}
USER root
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache ffmpeg mkvtoolnix; \
elif command -v apt-get >/dev/null 2>&1; then \
apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg mkvtoolnix \
&& rm -rf /var/lib/apt/lists/*; \
else \
echo "Unsupported base image: no apk or apt-get found" >&2; \
exit 1; \
fi
COPY docker/crd-linux-x64/ /opt/crd/
COPY docker/crd.desktop /usr/share/applications/crd.desktop
COPY CRD/Assets/app_icon.png /opt/crd/crd-icon.png
COPY docker/50-crd-shortcuts /custom-cont-init.d/50-crd-shortcuts
RUN chmod +x /opt/crd/CRD /opt/crd/Updater /custom-cont-init.d/50-crd-shortcuts \
&& rm -rf /opt/crd/config /opt/crd/temp /opt/crd/video /opt/crd/presets /opt/crd/fonts /opt/crd/widevine /opt/crd/lib /opt/crd/logfile.txt \
&& ln -s /crd-data/config /opt/crd/config \
&& ln -s /crd-data/temp /opt/crd/temp \
&& ln -s /crd-data/video /opt/crd/video \
&& ln -s /crd-data/presets /opt/crd/presets \
&& ln -s /crd-data/fonts /opt/crd/fonts \
&& ln -s /crd-data/widevine /opt/crd/widevine \
&& ln -s /crd-data/lib /opt/crd/lib \
&& ln -s /crd-data/logfile.txt /opt/crd/logfile.txt
WORKDIR /opt/crd

16
docker/50-crd-shortcuts Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/with-contenv sh
set -eu
desktop_dir="/config/Desktop"
autostart_dir="/config/.config/autostart"
desktop_file="/usr/share/applications/crd.desktop"
app_data_dir="/crd-data"
run_uid="${PUID:-1000}"
run_gid="${PGID:-1000}"
mkdir -p "$desktop_dir" "$autostart_dir"
mkdir -p "$app_data_dir/config" "$app_data_dir/temp" "$app_data_dir/video" "$app_data_dir/presets" "$app_data_dir/fonts" "$app_data_dir/widevine" "$app_data_dir/lib"
cp "$desktop_file" "$desktop_dir/CRD.desktop"
cp "$desktop_file" "$autostart_dir/CRD.desktop"
chmod +x "$desktop_dir/CRD.desktop" "$autostart_dir/CRD.desktop"
chown -R "$run_uid:$run_gid" "$app_data_dir" "$desktop_dir" "$autostart_dir"

9
docker/crd.desktop Normal file
View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=CRD
Comment=Crunchy Downloader
Exec=/opt/crd/CRD
Icon=/opt/crd/crd-icon.png
Terminal=false
Categories=AudioVideo;Network;

BIN
images/Calendar_Custom_Settings.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview_Table.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_CR_Download.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Dub.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Hardsub.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Muxing.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Muxing_New_Encoding_Preset.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Softsubs.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_Download.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_Download_CR.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