Mayo commited on
Commit
61e9e7a
·
unverified ·
1 Parent(s): ec765dc

feat: download progress indicator

Browse files
Cargo.lock CHANGED
@@ -4063,9 +4063,11 @@ dependencies = [
4063
  "blake3",
4064
  "clap",
4065
  "dirs",
 
4066
  "git-version",
4067
  "icu",
4068
  "image",
 
4069
  "koharu-ml",
4070
  "koharu-renderer",
4071
  "koharu-runtime",
@@ -4097,12 +4099,16 @@ name = "koharu-core"
4097
  version = "0.29.0"
4098
  dependencies = [
4099
  "anyhow",
 
4100
  "futures",
 
4101
  "indicatif",
4102
  "once_cell",
4103
  "reqwest",
4104
  "reqwest-middleware",
4105
  "reqwest-retry",
 
 
4106
  "tracing",
4107
  ]
4108
 
@@ -4118,7 +4124,6 @@ dependencies = [
4118
  "cudarc 0.19.0",
4119
  "dirs",
4120
  "futures",
4121
- "hf-hub",
4122
  "image",
4123
  "imageproc",
4124
  "koharu-core",
 
4063
  "blake3",
4064
  "clap",
4065
  "dirs",
4066
+ "futures",
4067
  "git-version",
4068
  "icu",
4069
  "image",
4070
+ "koharu-core",
4071
  "koharu-ml",
4072
  "koharu-renderer",
4073
  "koharu-runtime",
 
4099
  version = "0.29.0"
4100
  dependencies = [
4101
  "anyhow",
4102
+ "dirs",
4103
  "futures",
4104
+ "hf-hub",
4105
  "indicatif",
4106
  "once_cell",
4107
  "reqwest",
4108
  "reqwest-middleware",
4109
  "reqwest-retry",
4110
+ "serde",
4111
+ "tokio",
4112
  "tracing",
4113
  ]
4114
 
 
4124
  "cudarc 0.19.0",
4125
  "dirs",
4126
  "futures",
 
4127
  "image",
4128
  "imageproc",
4129
  "koharu-core",
koharu-core/Cargo.toml CHANGED
@@ -13,10 +13,14 @@ publish.workspace = true
13
 
14
  [dependencies]
15
  anyhow = { workspace = true }
 
 
 
 
 
16
  reqwest = { workspace = true }
17
  reqwest-middleware = { workspace = true }
18
  reqwest-retry = { workspace = true }
19
- once_cell = { workspace = true }
20
- futures = { workspace = true }
21
- indicatif = { workspace = true }
22
  tracing = { workspace = true }
 
13
 
14
  [dependencies]
15
  anyhow = { workspace = true }
16
+ dirs = { workspace = true }
17
+ futures = { workspace = true }
18
+ hf-hub = { workspace = true }
19
+ indicatif = { workspace = true }
20
+ once_cell = { workspace = true }
21
  reqwest = { workspace = true }
22
  reqwest-middleware = { workspace = true }
23
  reqwest-retry = { workspace = true }
24
+ serde = { workspace = true }
25
+ tokio = { workspace = true }
 
26
  tracing = { workspace = true }
koharu-core/src/download.rs ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+ use std::sync::Arc;
3
+
4
+ use anyhow::Context;
5
+ use futures::{StreamExt, TryStreamExt, stream};
6
+ use once_cell::sync::Lazy;
7
+ use reqwest::header::{ACCEPT_RANGES, CONTENT_LENGTH};
8
+ use serde::Serialize;
9
+ use tokio::sync::broadcast;
10
+ use tracing::Instrument;
11
+
12
+ use crate::hf_hub;
13
+ use crate::http::{http_chunk, http_client};
14
+ use crate::progress::progress_bar;
15
+
16
+ const RANGE_CHUNK_SIZE_BYTES: usize = 16 * 1024 * 1024;
17
+
18
+ #[derive(Clone, Debug, Serialize)]
19
+ pub struct DownloadProgress {
20
+ pub filename: String,
21
+ pub downloaded: u64,
22
+ pub total: Option<u64>,
23
+ pub status: Status,
24
+ }
25
+
26
+ #[derive(Clone, Debug, Serialize, PartialEq)]
27
+ pub enum Status {
28
+ Started,
29
+ Downloading,
30
+ Completed,
31
+ Failed(String),
32
+ }
33
+
34
+ static TX: Lazy<broadcast::Sender<DownloadProgress>> = Lazy::new(|| broadcast::channel(256).0);
35
+
36
+ pub fn subscribe() -> broadcast::Receiver<DownloadProgress> {
37
+ TX.subscribe()
38
+ }
39
+
40
+ pub(crate) fn emit(progress: DownloadProgress) {
41
+ let _ = TX.send(progress);
42
+ }
43
+
44
+ pub async fn model(repo: &str, filename: &str) -> anyhow::Result<PathBuf> {
45
+ let hf_repo = hf_hub::repo(repo);
46
+
47
+ if let Some(path) = hf_hub::cache().repo(hf_repo.clone()).get(filename) {
48
+ return Ok(path);
49
+ }
50
+
51
+ let reporter = hf_hub::Reporter::new(filename);
52
+
53
+ let path = hf_hub::api()
54
+ .repo(hf_repo)
55
+ .download_with_progress(filename, reporter)
56
+ .instrument(tracing::info_span!("hf_download", repo, filename))
57
+ .await
58
+ .context("failed to download from HF Hub")?;
59
+
60
+ Ok(path)
61
+ }
62
+
63
+ #[tracing::instrument(level = "info")]
64
+ pub async fn bytes(url: &str) -> anyhow::Result<Vec<u8>> {
65
+ let client = http_client();
66
+
67
+ let head = client
68
+ .head(url)
69
+ .send()
70
+ .await?
71
+ .error_for_status()
72
+ .context(format!("cannot download {url}"))?;
73
+
74
+ let headers = head.headers();
75
+ let total_bytes = headers
76
+ .get(CONTENT_LENGTH)
77
+ .and_then(|v| v.to_str().ok())
78
+ .and_then(|v| v.parse::<u64>().ok())
79
+ .context("missing Content-Length header")?;
80
+
81
+ anyhow::ensure!(total_bytes > 0, "resource reports zero Content-Length");
82
+
83
+ let supports_ranges = headers
84
+ .get(ACCEPT_RANGES)
85
+ .and_then(|v| v.to_str().ok())
86
+ .map(|v| v.eq_ignore_ascii_case("bytes"))
87
+ .unwrap_or(false);
88
+
89
+ anyhow::ensure!(
90
+ supports_ranges,
91
+ "remote server does not advertise byte ranges"
92
+ );
93
+
94
+ let filename = url.split('/').next_back().unwrap_or(url).to_string();
95
+ let pb = Arc::new(progress_bar(&filename));
96
+
97
+ let total_len =
98
+ usize::try_from(total_bytes).context("resource too large to fit into memory")?;
99
+ let chunk_size = total_len.clamp(1, RANGE_CHUNK_SIZE_BYTES);
100
+ let segments = total_len.div_ceil(chunk_size);
101
+
102
+ pb.set_length(total_len as u64);
103
+
104
+ emit(DownloadProgress {
105
+ filename: filename.clone(),
106
+ downloaded: 0,
107
+ total: Some(total_bytes),
108
+ status: Status::Started,
109
+ });
110
+
111
+ tracing::debug!(
112
+ %url,
113
+ total_bytes,
114
+ segments,
115
+ "downloading resource via HTTP range requests"
116
+ );
117
+
118
+ let downloaded = Arc::new(std::sync::atomic::AtomicU64::new(0));
119
+
120
+ let chunks = {
121
+ let url = Arc::new(url.to_string());
122
+ let pb = Arc::clone(&pb);
123
+ let downloaded = Arc::clone(&downloaded);
124
+ let filename = filename.clone();
125
+ stream::iter((0..segments).map(move |index| {
126
+ let start = (index * chunk_size) as u64;
127
+ let len = ((index + 1) * chunk_size).min(total_len) - (index * chunk_size);
128
+ let end = start + len as u64 - 1;
129
+ let url = Arc::clone(&url);
130
+ let pb = Arc::clone(&pb);
131
+ let downloaded = Arc::clone(&downloaded);
132
+ let filename = filename.clone();
133
+ async move {
134
+ let chunk = http_chunk(&url, start, end).await?;
135
+ pb.inc(len as u64);
136
+ let current = downloaded
137
+ .fetch_add(len as u64, std::sync::atomic::Ordering::Relaxed)
138
+ + len as u64;
139
+ emit(DownloadProgress {
140
+ filename,
141
+ downloaded: current,
142
+ total: Some(total_bytes),
143
+ status: Status::Downloading,
144
+ });
145
+ Ok::<_, anyhow::Error>((start, chunk))
146
+ }
147
+ }))
148
+ .buffer_unordered(segments)
149
+ .try_collect::<Vec<_>>()
150
+ .await
151
+ };
152
+
153
+ let chunks = match chunks {
154
+ Ok(c) => c,
155
+ Err(e) => {
156
+ emit(DownloadProgress {
157
+ filename,
158
+ downloaded: downloaded.load(std::sync::atomic::Ordering::Relaxed),
159
+ total: Some(total_bytes),
160
+ status: Status::Failed(e.to_string()),
161
+ });
162
+ return Err(e);
163
+ }
164
+ };
165
+
166
+ pb.finish_and_clear();
167
+
168
+ let mut parts = chunks;
169
+ parts.sort_by_key(|(start, _)| *start);
170
+
171
+ let mut buffer = Vec::with_capacity(total_len);
172
+ for (_start, mut chunk) in parts {
173
+ buffer.append(&mut chunk);
174
+ }
175
+
176
+ anyhow::ensure!(
177
+ buffer.len() == total_len,
178
+ "range assembly mismatch: expected {} bytes, got {}",
179
+ total_len,
180
+ buffer.len()
181
+ );
182
+
183
+ emit(DownloadProgress {
184
+ filename,
185
+ downloaded: total_bytes,
186
+ total: Some(total_bytes),
187
+ status: Status::Completed,
188
+ });
189
+
190
+ Ok(buffer)
191
+ }
koharu-core/src/hf_hub.rs ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+ use std::sync::Arc;
3
+ use std::sync::atomic::{AtomicU64, Ordering};
4
+
5
+ use hf_hub::{
6
+ Cache, Repo,
7
+ api::tokio::{Api, ApiBuilder, Progress},
8
+ };
9
+ use indicatif::ProgressBar;
10
+ use once_cell::sync::{Lazy, OnceCell};
11
+
12
+ use crate::download::{DownloadProgress, Status, emit};
13
+ use crate::progress::progress_bar;
14
+
15
+ static CACHE_DIR: OnceCell<PathBuf> = OnceCell::new();
16
+
17
+ static HF_API: Lazy<Api> = Lazy::new(|| {
18
+ ApiBuilder::new()
19
+ .with_cache_dir(get_cache_dir().to_path_buf())
20
+ .high()
21
+ .build()
22
+ .expect("build HF API client")
23
+ });
24
+ static HF_CACHE: Lazy<Cache> = Lazy::new(|| Cache::new(get_cache_dir().to_path_buf()));
25
+
26
+ fn get_cache_dir() -> &'static PathBuf {
27
+ CACHE_DIR.get_or_init(|| {
28
+ dirs::cache_dir()
29
+ .unwrap_or_default()
30
+ .join("Koharu")
31
+ .join("models")
32
+ })
33
+ }
34
+
35
+ pub fn set_cache_dir(path: PathBuf) -> anyhow::Result<()> {
36
+ CACHE_DIR
37
+ .set(path)
38
+ .map_err(|_| anyhow::anyhow!("cache dir has already been set"))
39
+ }
40
+
41
+ pub fn api() -> &'static Api {
42
+ &HF_API
43
+ }
44
+
45
+ pub fn cache() -> &'static Cache {
46
+ &HF_CACHE
47
+ }
48
+
49
+ pub fn repo(name: &str) -> Repo {
50
+ Repo::model(name.to_string())
51
+ }
52
+
53
+ #[derive(Clone)]
54
+ pub(crate) struct Reporter {
55
+ pb: ProgressBar,
56
+ filename: String,
57
+ downloaded: Arc<AtomicU64>,
58
+ total: u64,
59
+ }
60
+
61
+ impl Reporter {
62
+ pub fn new(filename: &str) -> Self {
63
+ Self {
64
+ pb: progress_bar(filename),
65
+ filename: filename.to_string(),
66
+ downloaded: Arc::new(AtomicU64::new(0)),
67
+ total: 0,
68
+ }
69
+ }
70
+ }
71
+
72
+ impl Progress for Reporter {
73
+ async fn init(&mut self, size: usize, filename: &str) {
74
+ self.filename = filename.to_string();
75
+ self.downloaded.store(0, Ordering::Relaxed);
76
+ self.total = size as u64;
77
+ self.pb.set_length(size as u64);
78
+ self.pb.set_position(0);
79
+ emit(DownloadProgress {
80
+ filename: self.filename.clone(),
81
+ downloaded: 0,
82
+ total: Some(self.total),
83
+ status: Status::Started,
84
+ });
85
+ }
86
+
87
+ async fn update(&mut self, size: usize) {
88
+ let current = self.downloaded.fetch_add(size as u64, Ordering::Relaxed) + size as u64;
89
+ self.pb.inc(size as u64);
90
+ emit(DownloadProgress {
91
+ filename: self.filename.clone(),
92
+ downloaded: current,
93
+ total: Some(self.total),
94
+ status: Status::Downloading,
95
+ });
96
+ }
97
+
98
+ async fn finish(&mut self) {
99
+ self.pb.finish_and_clear();
100
+ emit(DownloadProgress {
101
+ filename: self.filename.clone(),
102
+ downloaded: self.downloaded.load(Ordering::Relaxed),
103
+ total: Some(self.total),
104
+ status: Status::Completed,
105
+ });
106
+ }
107
+ }
koharu-core/src/http.rs CHANGED
@@ -1,16 +1,11 @@
1
- use std::sync::Arc;
2
-
3
  use anyhow::Context;
4
- use futures::{StreamExt, TryStreamExt, stream};
5
  use once_cell::sync::Lazy;
6
- use reqwest::header::{ACCEPT_RANGES, CONTENT_LENGTH, RANGE};
7
  use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
8
  use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
9
 
10
- use crate::progress::progress_bar;
11
-
12
  const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
13
- const RANGE_CHUNK_SIZE_BYTES: usize = 16 * 1024 * 1024;
14
 
15
  static HTTP_CLIENT: Lazy<ClientWithMiddleware> = Lazy::new(|| {
16
  ClientBuilder::new(
@@ -29,91 +24,7 @@ pub fn http_client() -> &'static ClientWithMiddleware {
29
  &HTTP_CLIENT
30
  }
31
 
32
- #[tracing::instrument(level = "info")]
33
- pub async fn http_download(url: &str) -> anyhow::Result<Vec<u8>> {
34
- let head = HTTP_CLIENT
35
- .head(url)
36
- .send()
37
- .await?
38
- .error_for_status()
39
- .context(format!("cannot download {url}"))?;
40
- let headers = head.headers();
41
- let total_bytes = headers
42
- .get(CONTENT_LENGTH)
43
- .and_then(|value| value.to_str().ok())
44
- .and_then(|value| value.parse::<u64>().ok())
45
- .context("missing Content-Length header")?;
46
-
47
- anyhow::ensure!(total_bytes > 0, "resource reports zero Content-Length");
48
-
49
- let supports_ranges = headers
50
- .get(ACCEPT_RANGES)
51
- .and_then(|value| value.to_str().ok())
52
- .map(|value| value.eq_ignore_ascii_case("bytes"))
53
- .unwrap_or(false);
54
-
55
- anyhow::ensure!(
56
- supports_ranges,
57
- "remote server does not advertise byte ranges"
58
- );
59
-
60
- let pb = Arc::new(progress_bar(url.split('/').next_back().unwrap_or(url)));
61
-
62
- let total_len =
63
- usize::try_from(total_bytes).context("resource too large to fit into memory")?;
64
- let chunk_size = total_len.clamp(1, RANGE_CHUNK_SIZE_BYTES);
65
- let segments = total_len.div_ceil(chunk_size);
66
-
67
- pb.set_length(total_len as u64);
68
-
69
- tracing::debug!(
70
- %url,
71
- total_bytes,
72
- segments,
73
- "downloading resource via HTTP range requests"
74
- );
75
-
76
- let chunks = {
77
- let url = Arc::new(url.to_string());
78
- let pb = Arc::clone(&pb);
79
- stream::iter((0..segments).map(move |index| {
80
- let start = (index * chunk_size) as u64;
81
- let len = ((index + 1) * chunk_size).min(total_len) - (index * chunk_size);
82
- let end = start + len as u64 - 1;
83
- let url = Arc::clone(&url);
84
- let pb = Arc::clone(&pb);
85
- async move {
86
- let chunk = http_chunk(&url, start, end).await?;
87
- pb.inc(len as u64);
88
- Ok::<_, anyhow::Error>((start, chunk))
89
- }
90
- }))
91
- .buffer_unordered(segments)
92
- .try_collect::<Vec<_>>()
93
- .await?
94
- };
95
-
96
- pb.finish_and_clear();
97
-
98
- let mut parts = chunks;
99
- parts.sort_by_key(|(start, _)| *start);
100
-
101
- let mut buffer = Vec::with_capacity(total_len);
102
- for (_start, mut chunk) in parts {
103
- buffer.append(&mut chunk);
104
- }
105
-
106
- anyhow::ensure!(
107
- buffer.len() == total_len,
108
- "range assembly mismatch: expected {} bytes, got {}",
109
- total_len,
110
- buffer.len()
111
- );
112
-
113
- Ok(buffer)
114
- }
115
-
116
- async fn http_chunk(url: &str, start: u64, end: u64) -> anyhow::Result<Vec<u8>> {
117
  let expected_len = usize::try_from(end - start + 1)?;
118
  let response = HTTP_CLIENT
119
  .get(url)
 
 
 
1
  use anyhow::Context;
2
+ use futures::StreamExt;
3
  use once_cell::sync::Lazy;
4
+ use reqwest::header::RANGE;
5
  use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
6
  use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
7
 
 
 
8
  const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
 
9
 
10
  static HTTP_CLIENT: Lazy<ClientWithMiddleware> = Lazy::new(|| {
11
  ClientBuilder::new(
 
24
  &HTTP_CLIENT
25
  }
26
 
27
+ pub async fn http_chunk(url: &str, start: u64, end: u64) -> anyhow::Result<Vec<u8>> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  let expected_len = usize::try_from(end - start + 1)?;
29
  let response = HTTP_CLIENT
30
  .get(url)
koharu-core/src/lib.rs CHANGED
@@ -1,2 +1,4 @@
 
 
1
  pub mod http;
2
  pub mod progress;
 
1
+ pub mod download;
2
+ pub mod hf_hub;
3
  pub mod http;
4
  pub mod progress;
koharu-ml/Cargo.toml CHANGED
@@ -17,7 +17,6 @@ anyhow = { workspace = true }
17
  clap = { workspace = true }
18
  image = { workspace = true }
19
  imageproc = { workspace = true }
20
- hf-hub = { workspace = true }
21
  candle-core = { workspace = true }
22
  candle-transformers = { workspace = true }
23
  candle-nn = { workspace = true }
@@ -30,7 +29,6 @@ tracing-subscriber = { workspace = true }
30
  tokio = { workspace = true }
31
  rustfft = { workspace = true }
32
  once_cell = { workspace = true }
33
- dirs = { workspace = true }
34
  futures = { workspace = true }
35
  num_cpus = { workspace = true }
36
  libloading = { workspace = true }
@@ -62,6 +60,9 @@ metal = [
62
  "objc2-metal-performance-shaders-graph",
63
  ]
64
 
 
 
 
65
  [[bin]]
66
  name = "comic-text-detector"
67
  path = "bin/comic-text-detector.rs"
 
17
  clap = { workspace = true }
18
  image = { workspace = true }
19
  imageproc = { workspace = true }
 
20
  candle-core = { workspace = true }
21
  candle-transformers = { workspace = true }
22
  candle-nn = { workspace = true }
 
29
  tokio = { workspace = true }
30
  rustfft = { workspace = true }
31
  once_cell = { workspace = true }
 
32
  futures = { workspace = true }
33
  num_cpus = { workspace = true }
34
  libloading = { workspace = true }
 
60
  "objc2-metal-performance-shaders-graph",
61
  ]
62
 
63
+ [dev-dependencies]
64
+ dirs = { workspace = true }
65
+
66
  [[bin]]
67
  name = "comic-text-detector"
68
  path = "bin/comic-text-detector.rs"
koharu-ml/src/hf_hub.rs CHANGED
@@ -1,57 +1,7 @@
1
- use std::path::PathBuf;
2
-
3
- use anyhow::Context;
4
- use hf_hub::{
5
- Cache, Repo,
6
- api::tokio::{Api, ApiBuilder},
7
- };
8
- use koharu_core::progress::progress_bar;
9
- use once_cell::sync::{Lazy, OnceCell};
10
-
11
- static CACHE_DIR: OnceCell<PathBuf> = OnceCell::new();
12
-
13
- static HF_API: Lazy<Api> = Lazy::new(|| {
14
- ApiBuilder::new()
15
- .with_cache_dir(get_cache_dir().to_path_buf())
16
- .high()
17
- .build()
18
- .expect("build HF API client")
19
- });
20
- static HF_CACHE: Lazy<Cache> = Lazy::new(|| Cache::new(get_cache_dir().to_path_buf()));
21
-
22
- fn get_cache_dir() -> &'static PathBuf {
23
- CACHE_DIR.get_or_init(|| {
24
- dirs::cache_dir()
25
- .unwrap_or_default()
26
- .join("Koharu")
27
- .join("models")
28
- })
29
- }
30
-
31
- pub fn set_cache_dir(path: PathBuf) -> anyhow::Result<()> {
32
- CACHE_DIR
33
- .set(path)
34
- .map_err(|_| anyhow::anyhow!("cache dir has already been set"))
35
- }
36
-
37
- pub async fn hf_download(repo: &str, filename: &str) -> anyhow::Result<PathBuf> {
38
- let hf_repo = Repo::model(repo.to_string());
39
- if let Some(path) = HF_CACHE.repo(hf_repo.clone()).get(filename) {
40
- return Ok(path);
41
- }
42
-
43
- let span = tracing::info_span!("hf_download", repo, filename);
44
- let _enter = span.enter();
45
-
46
- let pb = progress_bar(filename);
47
- let path = HF_API
48
- .repo(hf_repo)
49
- .download_with_progress(filename, pb)
50
- .await
51
- .context("failed to download from HF Hub")?;
52
-
53
- Ok(path)
54
- }
55
 
56
  #[macro_export]
57
  macro_rules! define_models {
@@ -67,10 +17,9 @@ macro_rules! define_models {
67
  impl Manifest {
68
  pub async fn get(&self) -> anyhow::Result<std::path::PathBuf> {
69
  use strum::EnumProperty;
70
- use $crate::hf_hub::hf_download;
71
  let repo = self.get_str("repo").expect("repo property");
72
  let filename = self.get_str("filename").expect("filename property");
73
- hf_download(repo, filename).await
74
  }
75
  }
76
 
 
1
+ #[allow(unused_imports)]
2
+ pub use koharu_core::download::model;
3
+ #[allow(unused_imports)]
4
+ pub use koharu_core::hf_hub::set_cache_dir;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  #[macro_export]
7
  macro_rules! define_models {
 
17
  impl Manifest {
18
  pub async fn get(&self) -> anyhow::Result<std::path::PathBuf> {
19
  use strum::EnumProperty;
 
20
  let repo = self.get_str("repo").expect("repo property");
21
  let filename = self.get_str("filename").expect("filename property");
22
+ koharu_core::download::model(repo, filename).await
23
  }
24
  }
25
 
koharu-ml/src/lib.rs CHANGED
@@ -9,7 +9,7 @@ pub mod manga_ocr;
9
  use anyhow::Result;
10
  use candle_core::{Device, utils::metal_is_available};
11
 
12
- pub use hf_hub::set_cache_dir;
13
  pub use llm::{language_from_tag, set_default_locale, set_locale, supported_locales};
14
 
15
  /// Name of the compute device being used.
 
9
  use anyhow::Result;
10
  use candle_core::{Device, utils::metal_is_available};
11
 
12
+ pub use koharu_core::hf_hub::set_cache_dir;
13
  pub use llm::{language_from_tag, set_default_locale, set_locale, supported_locales};
14
 
15
  /// Name of the compute device being used.
koharu-runtime/src/dylib.rs CHANGED
@@ -7,7 +7,8 @@ use std::{
7
 
8
  use anyhow::{Context, Result};
9
  use futures::stream::{self, StreamExt, TryStreamExt};
10
- use koharu_core::http::{http_client, http_download};
 
11
  use once_cell::sync::OnceCell;
12
  use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
13
  use tokio::task;
@@ -281,7 +282,7 @@ async fn fetch_and_extract(pkg: &str, platform_tags: &[&str], out_dir: Arc<PathB
281
 
282
  if needs_download {
283
  debug!("{pkg}: downloading {wheel_name}...");
284
- let bytes = http_download(&wheel_url)
285
  .await
286
  .context("failed to download wheel")?;
287
  let out = Arc::clone(&out_dir);
 
7
 
8
  use anyhow::{Context, Result};
9
  use futures::stream::{self, StreamExt, TryStreamExt};
10
+ use koharu_core::download;
11
+ use koharu_core::http::http_client;
12
  use once_cell::sync::OnceCell;
13
  use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
14
  use tokio::task;
 
282
 
283
  if needs_download {
284
  debug!("{pkg}: downloading {wheel_name}...");
285
+ let bytes = download::bytes(&wheel_url)
286
  .await
287
  .context("failed to download wheel")?;
288
  let out = Arc::clone(&out_dir);
koharu/Cargo.toml CHANGED
@@ -22,10 +22,12 @@ include = [
22
  ]
23
 
24
  [dependencies]
 
25
  koharu-ml = { workspace = true }
26
  koharu-renderer = { workspace = true }
27
  koharu-runtime = { workspace = true }
28
  anyhow = { workspace = true }
 
29
  rfd = { workspace = true }
30
  open = { workspace = true }
31
  serde = { workspace = true }
 
22
  ]
23
 
24
  [dependencies]
25
+ koharu-core = { workspace = true }
26
  koharu-ml = { workspace = true }
27
  koharu-renderer = { workspace = true }
28
  koharu-runtime = { workspace = true }
29
  anyhow = { workspace = true }
30
+ futures = { workspace = true }
31
  rfd = { workspace = true }
32
  open = { workspace = true }
33
  serde = { workspace = true }
koharu/src/app.rs CHANGED
@@ -251,7 +251,10 @@ pub async fn run() -> Result<()> {
251
  }
252
 
253
  tauri::Builder::default()
254
- .invoke_handler(tauri::generate_handler![command::initialize])
 
 
 
255
  .manage(AtomicU16::new(0))
256
  .setup(move |app| {
257
  let handle = app.handle().clone();
 
251
  }
252
 
253
  tauri::Builder::default()
254
+ .invoke_handler(tauri::generate_handler![
255
+ command::initialize,
256
+ command::download_progress,
257
+ ])
258
  .manage(AtomicU16::new(0))
259
  .setup(move |app| {
260
  let handle = app.handle().clone();
koharu/src/command.rs CHANGED
@@ -1,23 +1,39 @@
1
  use std::sync::atomic::{AtomicU16, Ordering};
2
 
3
  use tauri::State;
 
4
 
5
  use crate::result::Result;
6
 
7
  #[tauri::command]
8
  pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
9
- let mut attempts = 0;
10
  loop {
11
  let p = port.load(Ordering::SeqCst);
12
  if p != 0 {
13
  return Ok(p);
14
  }
15
 
16
- if attempts >= 100 {
17
- return Err(anyhow::anyhow!("HTTP server failed to start").into());
18
- }
19
-
20
- attempts += 1;
21
  tokio::time::sleep(std::time::Duration::from_millis(50)).await;
22
  }
23
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  use std::sync::atomic::{AtomicU16, Ordering};
2
 
3
  use tauri::State;
4
+ use tauri::ipc::Channel;
5
 
6
  use crate::result::Result;
7
 
8
  #[tauri::command]
9
  pub async fn initialize(port: State<'_, AtomicU16>) -> Result<u16> {
 
10
  loop {
11
  let p = port.load(Ordering::SeqCst);
12
  if p != 0 {
13
  return Ok(p);
14
  }
15
 
 
 
 
 
 
16
  tokio::time::sleep(std::time::Duration::from_millis(50)).await;
17
  }
18
  }
19
+
20
+ #[tauri::command]
21
+ pub async fn download_progress(
22
+ channel: Channel<koharu_core::download::DownloadProgress>,
23
+ ) -> Result<()> {
24
+ let mut rx = koharu_core::download::subscribe();
25
+ tokio::spawn(async move {
26
+ loop {
27
+ match rx.recv().await {
28
+ Ok(progress) => {
29
+ if channel.send(progress).is_err() {
30
+ break;
31
+ }
32
+ }
33
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
34
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
35
+ }
36
+ }
37
+ });
38
+ Ok(())
39
+ }
koharu/src/endpoints.rs CHANGED
@@ -1,11 +1,16 @@
1
  use std::{io::Cursor, path::PathBuf, str::FromStr};
2
 
 
 
3
  use axum::{
4
  Json,
5
  body::Body,
6
  extract::{Multipart, State},
7
  http::{HeaderValue, StatusCode, header},
8
- response::Response,
 
 
 
9
  };
10
  use image::{GenericImageView, ImageFormat, RgbaImage};
11
  use koharu_ml::llm::ModelId;
@@ -829,3 +834,18 @@ fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDy
829
  let blank = RgbaImage::from_pixel(width, height, color);
830
  image::DynamicImage::ImageRgba8(blank).into()
831
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  use std::{io::Cursor, path::PathBuf, str::FromStr};
2
 
3
+ use std::convert::Infallible;
4
+
5
  use axum::{
6
  Json,
7
  body::Body,
8
  extract::{Multipart, State},
9
  http::{HeaderValue, StatusCode, header},
10
+ response::{
11
+ Response,
12
+ sse::{Event, KeepAlive, Sse},
13
+ },
14
  };
15
  use image::{GenericImageView, ImageFormat, RgbaImage};
16
  use koharu_ml::llm::ModelId;
 
834
  let blank = RgbaImage::from_pixel(width, height, color);
835
  image::DynamicImage::ImageRgba8(blank).into()
836
  }
837
+
838
+ pub async fn download_progress()
839
+ -> Sse<impl futures::Stream<Item = std::result::Result<Event, Infallible>>> {
840
+ let rx = koharu_core::download::subscribe();
841
+ let stream = futures::stream::unfold(rx, |mut rx| async {
842
+ loop {
843
+ match rx.recv().await {
844
+ Ok(p) => return Some((Ok(Event::default().json_data(p).unwrap()), rx)),
845
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
846
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
847
+ }
848
+ }
849
+ });
850
+ Sse::new(stream).keep_alive(KeepAlive::default())
851
+ }
koharu/src/server.rs CHANGED
@@ -75,6 +75,7 @@ fn build_router(state: AppResources) -> Router {
75
  .route("/api/llm_offload", post(llm_offload))
76
  .route("/api/llm_ready", get(llm_ready).post(llm_ready))
77
  .route("/api/llm_generate", post(llm_generate))
 
78
  .with_state(state)
79
  .layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
80
  .layer(
 
75
  .route("/api/llm_offload", post(llm_offload))
76
  .route("/api/llm_ready", get(llm_ready).post(llm_ready))
77
  .route("/api/llm_generate", post(llm_generate))
78
+ .route("/api/download_progress", get(download_progress))
79
  .with_state(state)
80
  .layer(DefaultBodyLimit::max(1024 * 1024 * 1024))
81
  .layer(
koharu/tauri.conf.json CHANGED
@@ -28,8 +28,8 @@
28
  "url": "/splashscreen",
29
  "decorations": false,
30
  "resizable": false,
31
- "height": 150,
32
- "width": 200,
33
  "center": true
34
  }
35
  ]
 
28
  "url": "/splashscreen",
29
  "decorations": false,
30
  "resizable": false,
31
+ "height": 200,
32
+ "width": 300,
33
  "center": true
34
  }
35
  ]
ui/app/splashscreen/page.tsx CHANGED
@@ -1,9 +1,83 @@
1
  'use client'
2
 
 
3
  import { useTranslation } from 'react-i18next'
 
 
 
 
 
 
4
 
5
  export default function SplashScreen() {
6
  const { t } = useTranslation()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  return (
9
  <main className='bg-background flex min-h-screen flex-col items-center justify-center select-none'>
@@ -11,6 +85,26 @@ export default function SplashScreen() {
11
  <span className='text-primary mt-2 text-lg'>
12
  {t('common.initializing')}
13
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </main>
15
  )
16
  }
 
1
  'use client'
2
 
3
+ import { useEffect, useRef, useState } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
+ import { isTauri, type DownloadProgress } from '@/lib/backend'
6
+
7
+ type AggregateProgress = {
8
+ filename: string
9
+ percent?: number
10
+ }
11
 
12
  export default function SplashScreen() {
13
  const { t } = useTranslation()
14
+ const [progress, setProgress] = useState<AggregateProgress | null>(null)
15
+ const channelRef = useRef<unknown>(null)
16
+ const filesRef = useRef<Map<string, { downloaded: number; total: number }>>(
17
+ new Map(),
18
+ )
19
+
20
+ useEffect(() => {
21
+ if (!isTauri()) return
22
+ ;(async () => {
23
+ const { Channel, invoke } = await import('@tauri-apps/api/core')
24
+ const channel = new Channel<DownloadProgress>()
25
+ channelRef.current = channel
26
+ channel.onmessage = (msg) => {
27
+ const files = filesRef.current
28
+
29
+ if (msg.status === 'Started') {
30
+ files.set(msg.filename, { downloaded: 0, total: msg.total ?? 0 })
31
+ } else if (msg.status === 'Downloading') {
32
+ const entry = files.get(msg.filename)
33
+ if (entry) {
34
+ entry.downloaded = msg.downloaded
35
+ if (msg.total) entry.total = msg.total
36
+ } else {
37
+ files.set(msg.filename, {
38
+ downloaded: msg.downloaded,
39
+ total: msg.total ?? 0,
40
+ })
41
+ }
42
+ } else {
43
+ // Completed or Failed — lock this file at 100%
44
+ const entry = files.get(msg.filename)
45
+ if (entry) {
46
+ entry.downloaded = entry.total
47
+ }
48
+ }
49
+
50
+ // Compute aggregate
51
+ let totalBytes = 0
52
+ let downloadedBytes = 0
53
+ for (const entry of files.values()) {
54
+ totalBytes += entry.total
55
+ downloadedBytes += entry.downloaded
56
+ }
57
+
58
+ // Find current active file (last non-completed)
59
+ const activeFilename =
60
+ msg.status === 'Started' || msg.status === 'Downloading'
61
+ ? msg.filename
62
+ : null
63
+
64
+ const percent =
65
+ totalBytes > 0
66
+ ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))
67
+ : undefined
68
+
69
+ if (activeFilename) {
70
+ setProgress({ filename: activeFilename, percent })
71
+ } else {
72
+ // All done — keep showing 100% briefly
73
+ setProgress((prev) =>
74
+ prev ? { ...prev, percent: percent ?? 100 } : null,
75
+ )
76
+ }
77
+ }
78
+ await invoke('download_progress', { channel })
79
+ })()
80
+ }, [])
81
 
82
  return (
83
  <main className='bg-background flex min-h-screen flex-col items-center justify-center select-none'>
 
85
  <span className='text-primary mt-2 text-lg'>
86
  {t('common.initializing')}
87
  </span>
88
+ <div className='mt-4 flex h-12 w-48 flex-col items-center gap-1.5'>
89
+ <span className='text-muted-foreground h-4 max-w-full truncate text-xs'>
90
+ {progress ? progress.filename : '\u00a0'}
91
+ </span>
92
+ <div className='bg-muted relative h-1.5 w-full overflow-hidden rounded-full'>
93
+ {progress && typeof progress.percent === 'number' ? (
94
+ <div
95
+ className='bg-primary h-full rounded-full transition-[width] duration-300'
96
+ style={{ width: `${progress.percent}%` }}
97
+ />
98
+ ) : (
99
+ <div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
100
+ )}
101
+ </div>
102
+ <span className='text-muted-foreground h-4 text-[11px] tabular-nums'>
103
+ {progress && typeof progress.percent === 'number'
104
+ ? `${progress.percent}%`
105
+ : '\u00a0'}
106
+ </span>
107
+ </div>
108
  </main>
109
  )
110
  }
ui/components/ActivityBubble.tsx CHANGED
@@ -1,8 +1,9 @@
1
  'use client'
2
 
3
- import { type ReactNode } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
  import { useAppStore } from '@/lib/store'
 
6
  import { Button } from '@/components/ui/button'
7
  import { type OperationState } from '@/lib/operations'
8
 
@@ -21,6 +22,55 @@ function BubbleCard({ children }: { children: ReactNode }) {
21
  )
22
  }
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  function OperationCard({
25
  operation,
26
  onCancel,
@@ -116,23 +166,7 @@ function OperationCard({
116
  </span>
117
  ) : null}
118
  </div>
119
- <div className='mt-3 flex items-center gap-2'>
120
- <div className='bg-muted relative h-1.5 flex-1 overflow-hidden rounded-full'>
121
- {typeof progress === 'number' ? (
122
- <div
123
- className='bg-primary h-full rounded-full transition-[width] duration-300'
124
- style={{ width: `${progress}%` }}
125
- />
126
- ) : (
127
- <div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
128
- )}
129
- </div>
130
- {typeof progress === 'number' && (
131
- <span className='text-muted-foreground w-12 text-right text-[11px] font-semibold tabular-nums'>
132
- {progress}%
133
- </span>
134
- )}
135
- </div>
136
  {operation.cancellable && (
137
  <div className='mt-3 flex justify-end'>
138
  <Button
@@ -158,12 +192,32 @@ export function ActivityBubble() {
158
  const { t } = useTranslation()
159
  const operation = useAppStore((state) => state.operation)
160
  const cancelOperation = useAppStore((state) => state.cancelOperation)
 
 
 
 
 
 
 
 
 
 
161
 
162
- if (!operation) return null
163
 
164
  return (
165
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
166
- <OperationCard operation={operation} onCancel={cancelOperation} t={t} />
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
  )
169
  }
 
1
  'use client'
2
 
3
+ import { type ReactNode, useEffect } from 'react'
4
  import { useTranslation } from 'react-i18next'
5
  import { useAppStore } from '@/lib/store'
6
+ import { useDownloadStore } from '@/lib/downloads'
7
  import { Button } from '@/components/ui/button'
8
  import { type OperationState } from '@/lib/operations'
9
 
 
22
  )
23
  }
24
 
25
+ function ProgressBar({ percent }: { percent?: number }) {
26
+ return (
27
+ <div className='mt-3 flex items-center gap-2'>
28
+ <div className='bg-muted relative h-1.5 flex-1 overflow-hidden rounded-full'>
29
+ {typeof percent === 'number' ? (
30
+ <div
31
+ className='bg-primary h-full rounded-full transition-[width] duration-300'
32
+ style={{ width: `${percent}%` }}
33
+ />
34
+ ) : (
35
+ <div className='activity-progress-indeterminate from-primary/40 via-primary to-primary/40 absolute inset-0 w-1/2 rounded-full bg-linear-to-r' />
36
+ )}
37
+ </div>
38
+ {typeof percent === 'number' && (
39
+ <span className='text-muted-foreground w-12 text-right text-[11px] font-semibold tabular-nums'>
40
+ {percent}%
41
+ </span>
42
+ )}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ function DownloadCard({
48
+ filename,
49
+ percent,
50
+ t,
51
+ }: {
52
+ filename: string
53
+ percent?: number
54
+ t: TranslateFunc
55
+ }) {
56
+ return (
57
+ <BubbleCard>
58
+ <div className='flex items-start gap-3'>
59
+ <div className='bg-primary mt-1 h-2.5 w-2.5 animate-pulse rounded-full shadow-[0_0_0_6px_hsl(var(--primary)/0.16)]' />
60
+ <div className='flex-1'>
61
+ <div className='text-foreground text-sm font-semibold'>
62
+ {t('download.title')}
63
+ </div>
64
+ <div className='text-muted-foreground truncate text-xs'>
65
+ {filename}
66
+ </div>
67
+ <ProgressBar percent={percent} />
68
+ </div>
69
+ </div>
70
+ </BubbleCard>
71
+ )
72
+ }
73
+
74
  function OperationCard({
75
  operation,
76
  onCancel,
 
166
  </span>
167
  ) : null}
168
  </div>
169
+ <ProgressBar percent={progress} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  {operation.cancellable && (
171
  <div className='mt-3 flex justify-end'>
172
  <Button
 
192
  const { t } = useTranslation()
193
  const operation = useAppStore((state) => state.operation)
194
  const cancelOperation = useAppStore((state) => state.cancelOperation)
195
+ const downloads = useDownloadStore((s) => s.downloads)
196
+ const ensureSubscribed = useDownloadStore((s) => s.ensureSubscribed)
197
+
198
+ useEffect(() => {
199
+ ensureSubscribed()
200
+ }, [ensureSubscribed])
201
+
202
+ const activeDownloads = Array.from(downloads.values()).filter(
203
+ (d) => d.status === 'Started' || d.status === 'Downloading',
204
+ )
205
 
206
+ if (!operation && activeDownloads.length === 0) return null
207
 
208
  return (
209
  <div className='pointer-events-auto fixed right-6 bottom-6 z-100 flex w-80 max-w-[calc(100%-1.5rem)] flex-col gap-3'>
210
+ {operation && (
211
+ <OperationCard operation={operation} onCancel={cancelOperation} t={t} />
212
+ )}
213
+ {activeDownloads.map((d) => (
214
+ <DownloadCard
215
+ key={d.filename}
216
+ filename={d.filename}
217
+ percent={d.percent}
218
+ t={t}
219
+ />
220
+ ))}
221
  </div>
222
  )
223
  }
ui/lib/backend.ts CHANGED
@@ -224,6 +224,45 @@ export async function fetchThumbnail(index: number): Promise<Blob> {
224
  return res.blob()
225
  }
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  export const isTauri = isTauriEnv
228
 
229
  export const isMacOS = (): boolean => {
 
224
  return res.blob()
225
  }
226
 
227
+ export type DownloadProgress = {
228
+ filename: string
229
+ downloaded: number
230
+ total?: number
231
+ status: 'Started' | 'Downloading' | 'Completed' | { Failed: string }
232
+ }
233
+
234
+ export function subscribeDownloadProgress(
235
+ callback: (progress: DownloadProgress) => void,
236
+ ): () => void {
237
+ let stopped = false
238
+
239
+ ;(async () => {
240
+ await ensureInitialized()
241
+ const es = new EventSource(`${apiBase}/download_progress`)
242
+ es.onmessage = (event) => {
243
+ if (stopped) {
244
+ es.close()
245
+ return
246
+ }
247
+ try {
248
+ callback(JSON.parse(event.data) as DownloadProgress)
249
+ } catch (_) {}
250
+ }
251
+ const check = () => {
252
+ if (stopped) es.close()
253
+ }
254
+ const id = setInterval(check, 1000)
255
+ es.onerror = () => {
256
+ clearInterval(id)
257
+ es.close()
258
+ }
259
+ })()
260
+
261
+ return () => {
262
+ stopped = true
263
+ }
264
+ }
265
+
266
  export const isTauri = isTauriEnv
267
 
268
  export const isMacOS = (): boolean => {
ui/lib/downloads.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { create } from 'zustand'
4
+ import { subscribeDownloadProgress, type DownloadProgress } from '@/lib/backend'
5
+
6
+ type DownloadEntry = DownloadProgress & {
7
+ percent?: number
8
+ }
9
+
10
+ type DownloadStore = {
11
+ downloads: Map<string, DownloadEntry>
12
+ ensureSubscribed: () => void
13
+ }
14
+
15
+ let subscribed = false
16
+
17
+ export const useDownloadStore = create<DownloadStore>((set, get) => ({
18
+ downloads: new Map(),
19
+
20
+ ensureSubscribed: () => {
21
+ if (subscribed) return
22
+ subscribed = true
23
+
24
+ subscribeDownloadProgress((progress) => {
25
+ const next = new Map(get().downloads)
26
+ const percent =
27
+ progress.total && progress.total > 0
28
+ ? Math.round((progress.downloaded / progress.total) * 100)
29
+ : undefined
30
+
31
+ const status = progress.status
32
+ if (
33
+ status === 'Completed' ||
34
+ (typeof status === 'object' && 'Failed' in status)
35
+ ) {
36
+ next.set(progress.filename, { ...progress, percent })
37
+ set({ downloads: next })
38
+ setTimeout(() => {
39
+ const current = get().downloads
40
+ if (current.has(progress.filename)) {
41
+ const updated = new Map(current)
42
+ updated.delete(progress.filename)
43
+ set({ downloads: updated })
44
+ }
45
+ }, 3000)
46
+ } else {
47
+ next.set(progress.filename, { ...progress, percent })
48
+ set({ downloads: next })
49
+ }
50
+ })
51
+ },
52
+ }))
ui/public/locales/en-US/translation.json CHANGED
@@ -147,6 +147,9 @@
147
  "repairBrush": "Repair brush",
148
  "eraser": "Eraser"
149
  },
 
 
 
150
  "settings": {
151
  "title": "Settings",
152
  "appearance": "Appearance",
 
147
  "repairBrush": "Repair brush",
148
  "eraser": "Eraser"
149
  },
150
+ "download": {
151
+ "title": "Downloading"
152
+ },
153
  "settings": {
154
  "title": "Settings",
155
  "appearance": "Appearance",
ui/public/locales/ja-JP/translation.json CHANGED
@@ -147,6 +147,9 @@
147
  "repairBrush": "リペアブラシ",
148
  "eraser": "消しゴム"
149
  },
 
 
 
150
  "settings": {
151
  "title": "設定",
152
  "appearance": "外観",
 
147
  "repairBrush": "リペアブラシ",
148
  "eraser": "消しゴム"
149
  },
150
+ "download": {
151
+ "title": "ダウンロード中"
152
+ },
153
  "settings": {
154
  "title": "設定",
155
  "appearance": "外観",
ui/public/locales/zh-CN/translation.json CHANGED
@@ -147,6 +147,9 @@
147
  "repairBrush": "修复笔",
148
  "eraser": "橡皮擦"
149
  },
 
 
 
150
  "settings": {
151
  "title": "设置",
152
  "appearance": "外观",
 
147
  "repairBrush": "修复笔",
148
  "eraser": "橡皮擦"
149
  },
150
+ "download": {
151
+ "title": "下载中"
152
+ },
153
  "settings": {
154
  "title": "设置",
155
  "appearance": "外观",
ui/public/locales/zh-TW/translation.json CHANGED
@@ -147,6 +147,9 @@
147
  "repairBrush": "修復筆",
148
  "eraser": "橡皮擦"
149
  },
 
 
 
150
  "settings": {
151
  "title": "設定",
152
  "appearance": "外觀",
 
147
  "repairBrush": "修復筆",
148
  "eraser": "橡皮擦"
149
  },
150
+ "download": {
151
+ "title": "下載中"
152
+ },
153
  "settings": {
154
  "title": "設定",
155
  "appearance": "外觀",