summary refs log tree commit diff
diff options
context:
space:
mode:
authorTheArcaneBrony <myrainbowdash949@gmail.com>2023-10-09 00:26:46 +0200
committerTheArcaneBrony <myrainbowdash949@gmail.com>2023-10-09 00:26:46 +0200
commitbef1d59cffaae749661d0a5d90914839a54cc8e7 (patch)
treea05a726d27567bed093e77c194b1e0f9d2ac700f
downloadBatchBeatmapDownloader-bef1d59cffaae749661d0a5d90914839a54cc8e7.tar.xz
Initial commit HEAD master
-rw-r--r--.gitignore5
-rw-r--r--.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore13
-rw-r--r--.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml10
-rw-r--r--.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml4
-rw-r--r--.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml8
-rw-r--r--.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml6
-rw-r--r--BatchBeatmapDownloader.sln22
-rw-r--r--BatchBeatmapDownloader/App.axaml15
-rw-r--r--BatchBeatmapDownloader/App.axaml.cs23
-rw-r--r--BatchBeatmapDownloader/Assets/avalonia-logo.icobin0 -> 176111 bytes
-rw-r--r--BatchBeatmapDownloader/BatchBeatmapDownloader.csproj32
-rw-r--r--BatchBeatmapDownloader/ObjectCollectionWrapper.cs7
-rw-r--r--BatchBeatmapDownloader/Program.cs22
-rw-r--r--BatchBeatmapDownloader/ViewLocator.cs23
-rw-r--r--BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs31
-rw-r--r--BatchBeatmapDownloader/ViewModels/ViewModelBase.cs5
-rw-r--r--BatchBeatmapDownloader/Views/MainWindow.axaml93
-rw-r--r--BatchBeatmapDownloader/Views/MainWindow.axaml.cs34
-rw-r--r--BatchBeatmapDownloader/app.manifest18
-rw-r--r--LibBeatmapDownload/DomainStats.cs46
-rw-r--r--LibBeatmapDownload/DownloadTask.cs201
-rw-r--r--LibBeatmapDownload/DownloadTaskList.cs64
-rw-r--r--LibBeatmapDownload/LibBeatmapDownload.csproj14
-rw-r--r--LibBeatmapDownload/Status.cs10
24 files changed, 706 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..add57be
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+/packages/
+riderModule.iml
+/_ReSharper.Caches/
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore b/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore
new file mode 100644
index 0000000..7bca872
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/modules.xml
+/.idea.BatchBeatmapDownloader.iml
+/contentModel.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml
new file mode 100644
index 0000000..e4922d7
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/avalonia.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="AvaloniaProject">
+    <option name="projectPerEditor">
+      <map>
+        <entry key="BatchBeatmapDownloader/Views/MainWindow.axaml" value="BatchBeatmapDownloader/BatchBeatmapDownloader.csproj" />
+      </map>
+    </option>
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/encodings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="UserContentModel">
+    <attachedFolders />
+    <explicitIncludes />
+    <explicitExcludes />
+  </component>
+</project>
\ No newline at end of file
diff --git a/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml b/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/.idea.BatchBeatmapDownloader/.idea/vcs.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+  </component>
+</project>
\ No newline at end of file
diff --git a/BatchBeatmapDownloader.sln b/BatchBeatmapDownloader.sln
new file mode 100644
index 0000000..82783c5
--- /dev/null
+++ b/BatchBeatmapDownloader.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BatchBeatmapDownloader", "BatchBeatmapDownloader\BatchBeatmapDownloader.csproj", "{71940BDB-FA45-4F13-BAF0-285079C103D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibBeatmapDownload", "LibBeatmapDownload\LibBeatmapDownload.csproj", "{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{71940BDB-FA45-4F13-BAF0-285079C103D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{71940BDB-FA45-4F13-BAF0-285079C103D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{71940BDB-FA45-4F13-BAF0-285079C103D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{71940BDB-FA45-4F13-BAF0-285079C103D0}.Release|Any CPU.Build.0 = Release|Any CPU
+		{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{2E2E3BBD-8D47-4554-A8CD-1A51428383BD}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+EndGlobal
diff --git a/BatchBeatmapDownloader/App.axaml b/BatchBeatmapDownloader/App.axaml
new file mode 100644
index 0000000..0f4f164
--- /dev/null
+++ b/BatchBeatmapDownloader/App.axaml
@@ -0,0 +1,15 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="BatchBeatmapDownloader.App"
+             xmlns:local="using:BatchBeatmapDownloader"
+             RequestedThemeVariant="Default">
+             <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+    <Application.DataTemplates>
+        <local:ViewLocator/>
+    </Application.DataTemplates>
+  
+    <Application.Styles>
+        <FluentTheme />
+    </Application.Styles>
+</Application>
\ No newline at end of file
diff --git a/BatchBeatmapDownloader/App.axaml.cs b/BatchBeatmapDownloader/App.axaml.cs
new file mode 100644
index 0000000..0a1b270
--- /dev/null
+++ b/BatchBeatmapDownloader/App.axaml.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using BatchBeatmapDownloader.ViewModels;
+using BatchBeatmapDownloader.Views;
+
+namespace BatchBeatmapDownloader;
+
+public partial class App : Application {
+	public override void Initialize() {
+		AvaloniaXamlLoader.Load(this);
+	}
+
+	public override void OnFrameworkInitializationCompleted() {
+		if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
+			desktop.MainWindow = new MainWindow {
+				DataContext = new MainWindowViewModel(),
+			};
+		}
+
+		base.OnFrameworkInitializationCompleted();
+	}
+}
diff --git a/BatchBeatmapDownloader/Assets/avalonia-logo.ico b/BatchBeatmapDownloader/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..da8d49f
--- /dev/null
+++ b/BatchBeatmapDownloader/Assets/avalonia-logo.ico
Binary files differdiff --git a/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj b/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj
new file mode 100644
index 0000000..0e104cf
--- /dev/null
+++ b/BatchBeatmapDownloader/BatchBeatmapDownloader.csproj
@@ -0,0 +1,32 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>WinExe</OutputType>
+        <TargetFramework>net7.0</TargetFramework>
+        <Nullable>enable</Nullable>
+        <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
+        <ApplicationManifest>app.manifest</ApplicationManifest>
+        <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+        <LangVersion>preview</LangVersion>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <Folder Include="Models\"/>
+        <AvaloniaResource Include="Assets\**"/>
+    </ItemGroup>
+
+
+    <ItemGroup>
+        <PackageReference Include="Avalonia" Version="11.0.0"/>
+        <PackageReference Include="Avalonia.Desktop" Version="11.0.0"/>
+        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0"/>
+        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0"/>
+        <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
+        <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0"/>
+        <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0"/>
+    </ItemGroup>
+
+
+    <ItemGroup>
+      <ProjectReference Include="..\LibBeatmapDownload\LibBeatmapDownload.csproj" />
+    </ItemGroup>
+</Project>
diff --git a/BatchBeatmapDownloader/ObjectCollectionWrapper.cs b/BatchBeatmapDownloader/ObjectCollectionWrapper.cs
new file mode 100644
index 0000000..2171969
--- /dev/null
+++ b/BatchBeatmapDownloader/ObjectCollectionWrapper.cs
@@ -0,0 +1,7 @@
+using System.Collections.Generic;
+
+namespace BatchBeatmapDownloader;
+
+public class ObjectCollectionWrapper<T>(IEnumerable<T> items) {
+	public List<T> Items { get; set; } = new(items);
+}
diff --git a/BatchBeatmapDownloader/Program.cs b/BatchBeatmapDownloader/Program.cs
new file mode 100644
index 0000000..87236fd
--- /dev/null
+++ b/BatchBeatmapDownloader/Program.cs
@@ -0,0 +1,22 @@
+using System;
+using Avalonia;
+using Avalonia.ReactiveUI;
+
+namespace BatchBeatmapDownloader;
+
+internal class Program {
+	// Initialization code. Don't use any Avalonia, third-party APIs or any
+	// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+	// yet and stuff might break.
+	[STAThread]
+	public static void Main(string[] args) => BuildAvaloniaApp()
+		.StartWithClassicDesktopLifetime(args);
+
+	// Avalonia configuration, don't remove; also used by visual designer.
+	public static AppBuilder BuildAvaloniaApp()
+		=> AppBuilder.Configure<App>()
+			.UsePlatformDetect()
+			.WithInterFont()
+			.LogToTrace()
+			.UseReactiveUI();
+}
diff --git a/BatchBeatmapDownloader/ViewLocator.cs b/BatchBeatmapDownloader/ViewLocator.cs
new file mode 100644
index 0000000..e22d652
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewLocator.cs
@@ -0,0 +1,23 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using BatchBeatmapDownloader.ViewModels;
+
+namespace BatchBeatmapDownloader;
+
+public class ViewLocator : IDataTemplate {
+	public Control Build(object data) {
+		var name = data.GetType().FullName!.Replace("ViewModel", "View");
+		var type = Type.GetType(name);
+
+		if (type != null) {
+			return (Control)Activator.CreateInstance(type)!;
+		}
+
+		return new TextBlock { Text = "Not Found: " + name };
+	}
+
+	public bool Match(object data) {
+		return data is ViewModelBase;
+	}
+}
diff --git a/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs b/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..1e5d23a
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using LibBeatmapDownload;
+using ReactiveUI;
+
+namespace BatchBeatmapDownloader.ViewModels;
+
+public class MainWindowViewModel : ViewModelBase {
+	// public List<DownloadTask> DownloadTasks { get; set; } = new();
+	public DownloadTaskList DownloadTasks { get; set; } = new();
+
+	private int _windowWidth = 800;
+
+	public int WindowWidth {
+		set {
+			_windowWidth = value;
+			Debug.WriteLine($"Window width: {_windowWidth}");
+			DomainStatsChunked = DownloadTask.MirrorStats.Select(x => x.Value).ToList().Chunk(value/300)
+				.Select(x => new ObjectCollectionWrapper<DomainStats>(x)).ToList();
+			this.RaisePropertyChanged(nameof(DomainStatsChunked));
+		}
+	}
+
+	public List<ObjectCollectionWrapper<DomainStats>> DomainStatsChunked { get; set; } = DownloadTask.MirrorStats.Select(x => x.Value).ToList().Chunk(2)
+		.Select(x => new ObjectCollectionWrapper<DomainStats>(x)).ToList();
+
+	public void RaiseDownloadListChanged() {
+		this.RaisePropertyChanged(nameof(DownloadTasks));
+	}
+}
diff --git a/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs b/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..e0f04e3
--- /dev/null
+++ b/BatchBeatmapDownloader/ViewModels/ViewModelBase.cs
@@ -0,0 +1,5 @@
+using ReactiveUI;
+
+namespace BatchBeatmapDownloader.ViewModels;
+
+public class ViewModelBase : ReactiveObject { }
diff --git a/BatchBeatmapDownloader/Views/MainWindow.axaml b/BatchBeatmapDownloader/Views/MainWindow.axaml
new file mode 100644
index 0000000..d6ed94f
--- /dev/null
+++ b/BatchBeatmapDownloader/Views/MainWindow.axaml
@@ -0,0 +1,93 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:BatchBeatmapDownloader.ViewModels"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:batchBeatmapDownloader="clr-namespace:BatchBeatmapDownloader"
+        xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Collections"
+        xmlns:libBeatmapDownload="clr-namespace:LibBeatmapDownload;assembly=LibBeatmapDownload"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="BatchBeatmapDownloader.Views.MainWindow"
+        x:DataType="vm:MainWindowViewModel"
+        Icon="/Assets/avalonia-logo.ico"
+        Title="BatchBeatmapDownloader">
+
+    <Design.DataContext>
+        <!-- This only sets the DataContext for the previewer in an IDE,
+             to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
+        <vm:MainWindowViewModel/>
+    </Design.DataContext>
+    <StackPanel Orientation="Vertical">
+        <!-- horizontal listbox with progress bars -->
+        <ListBox ItemsSource="{Binding DomainStatsChunked}">
+            <ListBox.ItemsPanel>
+                <ItemsPanelTemplate>
+                    <UniformGrid Rows="{Binding DomainStatsChunked.Count}"/>
+                </ItemsPanelTemplate>
+            </ListBox.ItemsPanel>
+            <ListBox.ItemTemplate>
+                <DataTemplate>
+                    <ListBox ItemsSource="{Binding Items}">
+                        <ListBox.ItemsPanel>
+                            <ItemsPanelTemplate>
+                                <UniformGrid Columns="{Binding Items.Count}"/>
+                            </ItemsPanelTemplate>
+                        </ListBox.ItemsPanel>
+                        <ListBox.ItemTemplate>
+                            <DataTemplate DataType="libBeatmapDownload:DomainStats">
+                                <Grid>
+                                    <Grid.RowDefinitions>
+                                        <RowDefinition Height="Auto"/>
+                                        <RowDefinition Height="Auto"/>
+                                    </Grid.RowDefinitions>
+                                    <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+                                    <TextBlock Grid.Row="1" Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+                                </Grid>
+                            </DataTemplate>
+                        </ListBox.ItemTemplate>
+                    </ListBox>
+                </DataTemplate>
+            </ListBox.ItemTemplate>
+        </ListBox>
+        <!--
+        <ListBox ItemsSource="{Binding DomainStats}">
+            <ListBox.ItemsPanel>
+                <ItemsPanelTemplate>
+                    <UniformGrid Columns="{Binding DomainStats.Count}"/>
+                </ItemsPanelTemplate>
+            </ListBox.ItemsPanel>
+            <ListBox.ItemTemplate>
+                <DataTemplate DataType="batchBeatmapDownloader:DomainStats">
+                    <Grid>
+                        <Grid.RowDefinitions>
+                            <RowDefinition Height="Auto"/>
+                            <RowDefinition Height="Auto"/>
+                        </Grid.RowDefinitions>
+                        <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+                        <TextBlock Grid.Row="1" Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+                    </Grid>
+                </DataTemplate>
+            </ListBox.ItemTemplate>
+        </ListBox>
+        -->
+
+        <ListBox ItemsSource="{Binding DownloadTasks.Tasks}">
+            <ListBox.ItemsPanel>
+                <ItemsPanelTemplate>
+                    <VirtualizingStackPanel></VirtualizingStackPanel>
+                </ItemsPanelTemplate>
+            </ListBox.ItemsPanel>
+            <ListBox.ItemTemplate>
+                <DataTemplate DataType="libBeatmapDownload:DownloadTask">
+                    <!-- Progress bar with text overlayed -->
+                    <Grid>
+                        <ProgressBar Value="{Binding Progress, Mode=OneWay}" />
+                        <TextBlock Text="{Binding ProgressString, Mode=OneWay}" HorizontalAlignment="Center" VerticalAlignment="Center" />
+                    </Grid>
+
+                </DataTemplate>
+            </ListBox.ItemTemplate>
+        </ListBox>
+    </StackPanel>
+
+</Window>
diff --git a/BatchBeatmapDownloader/Views/MainWindow.axaml.cs b/BatchBeatmapDownloader/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..d062f92
--- /dev/null
+++ b/BatchBeatmapDownloader/Views/MainWindow.axaml.cs
@@ -0,0 +1,34 @@
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using ArcaneLibs.Extensions;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using BatchBeatmapDownloader.ViewModels;
+using LibBeatmapDownload;
+
+namespace BatchBeatmapDownloader.Views;
+
+public partial class MainWindow : Window {
+	public MainWindow() {
+		InitializeComponent();
+	}
+
+	protected override async void OnLoaded(RoutedEventArgs e) {
+		base.OnLoaded(e);
+		var ctx = DataContext as MainWindowViewModel;
+		Resized += (_, args) => ctx.WindowWidth = (int)args.ClientSize.Width;
+		var lines = File.ReadLinesAsync("/home/root@Rory/Downloads/maps.tsv");
+		await foreach (var line in lines) {
+			var parts = line.Split('\t');
+			var downloadTask = new DownloadTask(int.Parse(parts[0]), parts.Length > 1 ? parts[1] : null);
+			ctx?.DownloadTasks.Tasks.Add(downloadTask);
+			if (ctx!.DownloadTasks.Tasks.Count > 100) break;
+		}
+
+		var tasks = ctx.DownloadTasks.Tasks.Select(x => x.Download()).ToAsyncEnumerable();
+		await foreach (var result in tasks) {
+			Debug.WriteLine($"Downloaded {result}");
+		}
+	}
+}
diff --git a/BatchBeatmapDownloader/app.manifest b/BatchBeatmapDownloader/app.manifest
new file mode 100644
index 0000000..265e183
--- /dev/null
+++ b/BatchBeatmapDownloader/app.manifest
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <!-- This manifest is used on Windows only.
+       Don't remove it as it might cause problems with window transparency and embeded controls.
+       For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+  <assemblyIdentity version="1.0.0.0" name="BatchBeatmapDownloader.Desktop"/>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows 10 -->
+      <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+    </application>
+  </compatibility>
+</assembly>
diff --git a/LibBeatmapDownload/DomainStats.cs b/LibBeatmapDownload/DomainStats.cs
new file mode 100644
index 0000000..a026b7d
--- /dev/null
+++ b/LibBeatmapDownload/DomainStats.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace LibBeatmapDownload;
+
+public class DomainStats(string url) : INotifyPropertyChanged {
+	public string Domain { get; } = new Uri(url).Host;
+
+	private int _total = 0;
+
+	public int Total => Success + Failed;
+
+	private int _success = 0;
+
+	public int Success {
+		get => _success;
+		set => SetField(ref _success, value);
+	}
+
+	private int _failed = 0;
+
+	public int Failed {
+		get => _failed;
+		set => SetField(ref _failed, value);
+	}
+
+	public double Progress => Total == 0 ? 100 : (double)Success / Total * 100;
+	public string ProgressString => $"{Domain} {Progress/100:P0} ({Failed}/{Total} failed)";
+
+	public event PropertyChangedEventHandler? PropertyChanged;
+
+	private readonly Stopwatch sw = Stopwatch.StartNew();
+	protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Progress)));
+		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProgressString)));
+	}
+
+	protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+		if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+		field = value;
+		OnPropertyChanged(propertyName);
+		return true;
+	}
+}
diff --git a/LibBeatmapDownload/DownloadTask.cs b/LibBeatmapDownload/DownloadTask.cs
new file mode 100644
index 0000000..2e9222f
--- /dev/null
+++ b/LibBeatmapDownload/DownloadTask.cs
@@ -0,0 +1,201 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using ArcaneLibs;
+
+namespace LibBeatmapDownload;
+
+public class DownloadTask : INotifyPropertyChanged {
+	private static readonly HttpClient Client = new();
+
+	private static readonly List<string> Mirrors = new() {
+		"https://api.osu.direct/d/", "https://osu.direct/api/d/",
+		"https://api.chimu.moe/v1/download/",
+		"https://api.nerinyan.moe/d/", "https://proxy.nerinyan.moe/d/",
+		"https://storage.kurikku.pw/d/",
+		"https://storage.ripple.moe/d/",
+		"https://catboy.best/d/",
+		"https://txy1.sayobot.cn/beatmaps/download/full/"
+	};
+
+	public static readonly Dictionary<string, DomainStats> MirrorStats = Mirrors.ToDictionary(x => x, x => new DomainStats(x));
+
+	private static int _mirrorIndex = 0;
+
+	// private void AllPropertiesChanged() {
+	// typeof(DownloadTask).GetProperties().ToList().ForEach(x => OnPropertyChanged(x.Name));
+	// }
+
+	private static readonly Dictionary<string, DateTime> NextRatelimitFree = Mirrors.ToDictionary(x => x, x => DateTime.MinValue);
+
+	public int Id { get; }
+
+	private long _expectedSize = 0;
+
+	public long ExpectedSize {
+		get => _expectedSize;
+		set {
+			if (value == _expectedSize) return;
+			_expectedSize = value;
+			// OnPropertyChanged();
+			OnPropertyChanged(nameof(ProgressString));
+		}
+	}
+
+	private long _downloadedSize = 0;
+
+	private Stopwatch sw = Stopwatch.StartNew();
+
+	public long DownloadedSize {
+		get => _downloadedSize;
+		set {
+			if (value == _downloadedSize) return;
+			_downloadedSize = value;
+
+			//if (sw.ElapsedMilliseconds < 1000) return;
+			if(!value.ToString().EndsWith('0')) return;
+			OnPropertyChanged(nameof(Progress));
+			OnPropertyChanged(nameof(ProgressString));
+			sw.Restart();
+		}
+	}
+
+	public string OutPath { get; set; } = Path.GetTempFileName();
+
+	public int Attempt { get; set; } = 0;
+
+	private Status _status = Status.NotQueued;
+
+	public Status Status {
+		get => _status;
+		set {
+			if (value == _status) return;
+			_status = value;
+			OnPropertyChanged();
+			OnPropertyChanged(nameof(ProgressString));
+		}
+	}
+
+	public string? OriginalFilename { get; set; }
+	public double Progress => DownloadedSize / (double)(ExpectedSize == 0 ? 1 : ExpectedSize) * 100;
+
+	public string ProgressString => $"{Id}" +
+	                                $"{(_title == null ? "" : $" - {_title}")}" +
+	                                // $"{(OriginalFilename == null ? "" : $" ({OriginalFilename})")}" +
+	                                $": " +
+	                                Status switch {
+		                                Status.Downloading => $"{Progress / 100d:P2} ({Util.BytesToString(DownloadedSize)}/{Util.BytesToString(ExpectedSize)})",
+		                                Status.Finished => $"Finished downloading {Util.BytesToString(DownloadedSize)}",
+		                                Status.Error => $"Errored, retrying later, so far {Attempt} attempts...",
+		                                _ => Status
+	                                };
+
+	private static readonly SemaphoreSlim Semaphore = new(Mirrors.Count, Mirrors.Count);
+	private readonly int _id;
+	private readonly string? _title;
+
+	public static List<DownloadTask> DownloadTasks { get; set; } = new();
+	public DownloadTask(int id, string? title = null) {
+		_id = id;
+		_title = title;
+		Id = id;
+		DownloadTasks.Add(this);
+	}
+
+	public async Task<string?> Download() {
+		if(_status == Status.Error)
+			while (Semaphore.CurrentCount < Mirrors.Count/2) {
+				await Task.Delay(100);
+			}
+		Status = Status.Waiting;
+		var currentMirrorIndex = Interlocked.Increment(ref _mirrorIndex);
+		string mirror = Mirrors[currentMirrorIndex % Mirrors.Count];
+		await Semaphore.WaitAsync();
+		Status = Status.Connecting;
+
+		while (Attempt++ % Mirrors.Count == 0) {
+			mirror = Mirrors[currentMirrorIndex++ % Mirrors.Count];
+
+			if (DateTime.Now < NextRatelimitFree[mirror]) continue;
+			var url = $"{mirror}{Id}";
+			Debug.WriteLine($"Attempting to download {Id} from {mirror}: {url}");
+			var request = new HttpRequestMessage(HttpMethod.Get, url);
+			request.Headers.Add("Accept", "*/*");
+			// request.Headers.Add("User-Agent", "osu_beatmap_download_test");
+			// set user agent to entry binary name
+			request.Headers.Add("User-Agent", Assembly.GetEntryAssembly()?.GetName().Name ?? "LibBeatmapDownload");
+
+			var sw = Stopwatch.StartNew();
+			try {
+				var response = await Client.SendAsync(request);
+				Debug.WriteLine($"Got status code {(int)response.StatusCode} {response.StatusCode} for {url} after {sw.Elapsed}");
+				if (response.IsSuccessStatusCode) {
+					Status = Status.Downloading;
+					OriginalFilename = response.Content.Headers.ContentDisposition?.FileName ?? $"{_id}.osz";
+					ExpectedSize = response.Content.Headers.ContentLength ?? 1;
+					Debug.WriteLine(
+						$"Got success status code {response.StatusCode} from {mirror} for {_id} after {sw.Elapsed}, filename is {OriginalFilename}, expected size is {Util.BytesToString(ExpectedSize)}");
+					var streamWithFileBody = await response.Content.ReadAsStreamAsync();
+					// write to temp file first
+					await using var fileStream = File.Create(OutPath);
+					var buffer = new byte[8192];
+					int read;
+					while ((read = await streamWithFileBody.ReadAsync(buffer, 0, buffer.Length)) > 0) {
+						await fileStream.WriteAsync(buffer, 0, read);
+						DownloadedSize += read;
+						// Debug.WriteLine($"Read {read} bytes from {url}, total downloaded is {Util.BytesToString(DownloadedSize)}");
+					}
+
+					fileStream.Flush();
+					fileStream.Close();
+					Semaphore.Release();
+					Status = Status.Finished;
+					MirrorStats[mirror].Success++;
+					return OutPath;
+				}
+
+				Status = Status.Connecting;
+
+				//ratelimits
+				if (response.Headers.Contains("X-Burst-RateLimit-Reset")) {
+					NextRatelimitFree[mirror] = DateTime.Now.AddSeconds(int.Parse(response.Headers.GetValues("X-Burst-RateLimit-Reset").First()));
+					Debug.WriteLine($"We got burst ratelimited by {mirror}: {response.Headers.GetValues("X-Burst-RateLimit-Reset").First()} seconds");
+				}
+				else if (response.Headers.Contains("X-RateLimit-Reset")) {
+					NextRatelimitFree[mirror] = DateTime.Now.AddSeconds(int.Parse(response.Headers.GetValues("X-RateLimit-Reset").First()));
+					Debug.WriteLine($"We got ratelimited by {mirror}: {response.Headers.GetValues("X-RateLimit-Reset").First()} seconds");
+				}
+				else {
+					Debug.WriteLine($"Got non-success status code {response.StatusCode} from {mirror} for {_id} and no ratelimit headers were found...");
+					Debug.WriteLine(await response.Content.ReadAsStringAsync());
+				}
+			}
+			catch (Exception e) {
+				Debug.WriteLine(e.ToString());
+			}
+
+			MirrorStats[mirror].Failed++;
+		}
+
+		Status = Status.Error;
+		Debug.WriteLine($"Failed to download {_id} after {Attempt + 1} attempts, retrying...");
+		Semaphore.Release();
+		await Task.Delay(500);
+
+		return await Download();
+	}
+
+	public event PropertyChangedEventHandler? PropertyChanged;
+
+	protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+	}
+
+	protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+		if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+		field = value;
+		OnPropertyChanged(propertyName);
+		return true;
+	}
+}
diff --git a/LibBeatmapDownload/DownloadTaskList.cs b/LibBeatmapDownload/DownloadTaskList.cs
new file mode 100644
index 0000000..a449e96
--- /dev/null
+++ b/LibBeatmapDownload/DownloadTaskList.cs
@@ -0,0 +1,64 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+namespace LibBeatmapDownload;
+
+public class DownloadTaskList : INotifyPropertyChanged {
+	public DownloadTaskList() {
+		Tasks.CollectionChanged += (sender, args) => {
+			if (args.NewItems is { Count: > 0 })
+				foreach (var downloadTask in args.NewItems) {
+					if (downloadTask is not DownloadTask task) {
+						Debug.WriteLine($"[DownloadTaskList] New task is {downloadTask?.GetType().FullName ?? "null"}!");
+						continue;
+					}
+
+					task.PropertyChanged += (taskSender, taskArgs) => {
+						if (taskArgs.PropertyName == nameof(DownloadTask.Status)) {
+							if (task.Status == Status.Finished) {
+								Task.Run(async () => {
+									await Task.Delay(1000);
+									Tasks.Remove(task);
+								});
+							}
+
+							SetField(ref _tasks,
+								new ObservableCollection<DownloadTask>(
+									_tasks
+										.OrderByDescending(x => x.Progress)
+										.ThenByDescending(x => x.Status)
+								),
+								nameof(Tasks));
+							OnPropertyChanged(nameof(Tasks));
+						}
+					};
+				}
+
+			// OnPropertyChanged(nameof(Tasks));
+		};
+	}
+
+	public event PropertyChangedEventHandler? PropertyChanged;
+
+	private ObservableCollection<DownloadTask> _tasks = new();
+
+	public ObservableCollection<DownloadTask> Tasks {
+		get => _tasks;
+		set => SetField(ref _tasks, value);
+	}
+
+	protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+		PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+	}
+
+	protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) {
+		//check ObservableCollection by order
+
+		if (EqualityComparer<T>.Default.Equals(field, value)) return false;
+		field = value;
+		OnPropertyChanged(propertyName);
+		return true;
+	}
+}
diff --git a/LibBeatmapDownload/LibBeatmapDownload.csproj b/LibBeatmapDownload/LibBeatmapDownload.csproj
new file mode 100644
index 0000000..829366e
--- /dev/null
+++ b/LibBeatmapDownload/LibBeatmapDownload.csproj
@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <TargetFramework>net7.0</TargetFramework>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <LangVersion>preview</LangVersion>
+    </PropertyGroup>
+    
+    <ItemGroup>
+        <PackageReference Include="ArcaneLibs" Version="1.0.0-preview6437853305.78f6d30" />
+    </ItemGroup>
+
+</Project>
diff --git a/LibBeatmapDownload/Status.cs b/LibBeatmapDownload/Status.cs
new file mode 100644
index 0000000..f5602a4
--- /dev/null
+++ b/LibBeatmapDownload/Status.cs
@@ -0,0 +1,10 @@
+namespace LibBeatmapDownload;
+
+public enum Status {
+	NotQueued,
+	Error,
+	Waiting,
+	Connecting,
+	Downloading,
+	Finished
+}