Added torrent client and upload feature. reworked mobile UI.
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s
This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
@@ -26,4 +26,4 @@ tokio-cron-scheduler = "0.15"
|
|||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
librqbit = "8.1.1"
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ mod music;
|
|||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -370,7 +371,10 @@ impl Project for FuruProject {
|
|||||||
);
|
);
|
||||||
apps.register_with_views(api::ApiApp, "/api");
|
apps.register_with_views(api::ApiApp, "/api");
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
player::PlayerApp::new(Arc::clone(&self.app_config)),
|
player::PlayerApp::new(
|
||||||
|
Arc::clone(&self.app_config),
|
||||||
|
Arc::clone(&self.scheduler_handle),
|
||||||
|
),
|
||||||
"/api/player",
|
"/api/player",
|
||||||
);
|
);
|
||||||
if self.app_config.swagger_enabled {
|
if self.app_config.swagger_enabled {
|
||||||
|
|||||||
+132
-2
@@ -16,6 +16,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::i18n::Translations;
|
use crate::i18n::Translations;
|
||||||
|
use crate::scheduler::SchedulerHandle;
|
||||||
|
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -211,6 +213,11 @@ struct PathId {
|
|||||||
id: i64,
|
id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PathStringId {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SearchQuery {
|
struct SearchQuery {
|
||||||
q: String,
|
q: String,
|
||||||
@@ -2006,11 +2013,18 @@ async fn tracks_by_ids_handler(
|
|||||||
|
|
||||||
pub struct PlayerApp {
|
pub struct PlayerApp {
|
||||||
config: Arc<AppConfig>,
|
config: Arc<AppConfig>,
|
||||||
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerApp {
|
impl PlayerApp {
|
||||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
pub fn new(
|
||||||
Self { config }
|
config: Arc<AppConfig>,
|
||||||
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
scheduler_handle,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2022,6 +2036,8 @@ impl App for PlayerApp {
|
|||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
let pool_config = Arc::clone(&self.config);
|
let pool_config = Arc::clone(&self.config);
|
||||||
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
||||||
|
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
|
||||||
|
Arc::new(tokio::sync::OnceCell::new());
|
||||||
|
|
||||||
Router::with_urls([
|
Router::with_urls([
|
||||||
// -- Current user profile --
|
// -- Current user profile --
|
||||||
@@ -2049,6 +2065,120 @@ impl App for PlayerApp {
|
|||||||
},
|
},
|
||||||
"player_me",
|
"player_me",
|
||||||
),
|
),
|
||||||
|
// -- Torrent import widget --
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/preview",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(_user) = auth::get_session_user(&session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.preview(json.0).await {
|
||||||
|
Ok(preview) => Json(preview).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_preview",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/{id}/start",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
path: Path<PathStringId>,
|
||||||
|
json: Json<TorrentStartRequest>| {
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(_user) = auth::get_session_user(&session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service
|
||||||
|
.start(
|
||||||
|
&path.0.id,
|
||||||
|
json.0.selected_files,
|
||||||
|
live_config.agent_inbox_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_start",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/torrents/{id}/status",
|
||||||
|
{
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
get(
|
||||||
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
||||||
|
let torrent_service = Arc::clone(&torrent_service);
|
||||||
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
||||||
|
async move {
|
||||||
|
let Some(_user) = auth::get_session_user(&session, &db).await
|
||||||
|
else {
|
||||||
|
return Ok(json_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"not authenticated",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let service = torrent_service
|
||||||
|
.get_or_init(|| async {
|
||||||
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match service.status(&path.0.id).await {
|
||||||
|
Ok(job) => Json(job).into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"player_torrent_status",
|
||||||
|
),
|
||||||
// -- Artists (paginated) --
|
// -- Artists (paginated) --
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/artists",
|
"/artists",
|
||||||
|
|||||||
+537
@@ -0,0 +1,537 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use base64::Engine;
|
||||||
|
use librqbit::{
|
||||||
|
AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, Session, SessionOptions,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::{Mutex, OnceCell};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::scheduler::SchedulerHandle;
|
||||||
|
|
||||||
|
const METADATA_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentFileDto {
|
||||||
|
pub index: usize,
|
||||||
|
pub name: String,
|
||||||
|
pub components: Vec<String>,
|
||||||
|
pub length: u64,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentPreviewDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub info_hash: String,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub files: Vec<TorrentFileDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TorrentJobDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub info_hash: String,
|
||||||
|
pub status: String,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub selected_size: u64,
|
||||||
|
pub downloaded_bytes: u64,
|
||||||
|
pub progress_percent: f64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TorrentPreviewKind {
|
||||||
|
Magnet,
|
||||||
|
TorrentFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TorrentPreviewRequest {
|
||||||
|
pub kind: TorrentPreviewKind,
|
||||||
|
pub magnet: Option<String>,
|
||||||
|
pub torrent_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TorrentStartRequest {
|
||||||
|
pub selected_files: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TorrentJobStatus {
|
||||||
|
Preview,
|
||||||
|
Downloading,
|
||||||
|
Moving,
|
||||||
|
Complete,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentJobStatus {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Preview => "preview",
|
||||||
|
Self::Downloading => "downloading",
|
||||||
|
Self::Moving => "moving",
|
||||||
|
Self::Complete => "complete",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TorrentJob {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
info_hash: String,
|
||||||
|
torrent_bytes: Vec<u8>,
|
||||||
|
files: Vec<TorrentFileDto>,
|
||||||
|
status: TorrentJobStatus,
|
||||||
|
output_dir: PathBuf,
|
||||||
|
selected_files: Vec<usize>,
|
||||||
|
handle: Option<Arc<ManagedTorrent>>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentJob {
|
||||||
|
fn total_size(&self) -> u64 {
|
||||||
|
self.files.iter().map(|f| f.length).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_size(&self) -> u64 {
|
||||||
|
if self.selected_files.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| self.selected_files.contains(&f.index))
|
||||||
|
.map(|f| f.length)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dto(&self) -> TorrentJobDto {
|
||||||
|
let stats = self.handle.as_ref().map(|h| h.stats());
|
||||||
|
let downloaded_bytes = stats.as_ref().map(|s| s.progress_bytes).unwrap_or(0);
|
||||||
|
let total_bytes = stats
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.total_bytes)
|
||||||
|
.filter(|v| *v > 0)
|
||||||
|
.unwrap_or_else(|| self.selected_size());
|
||||||
|
let progress_percent = if total_bytes == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
downloaded_bytes as f64 / total_bytes as f64 * 100.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::dto_from_parts(
|
||||||
|
&self.id,
|
||||||
|
&self.name,
|
||||||
|
&self.info_hash,
|
||||||
|
self.status,
|
||||||
|
self.total_size(),
|
||||||
|
self.selected_size(),
|
||||||
|
downloaded_bytes,
|
||||||
|
progress_percent,
|
||||||
|
self.error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn dto_from_parts(
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
info_hash: &str,
|
||||||
|
status: TorrentJobStatus,
|
||||||
|
total_size: u64,
|
||||||
|
selected_size: u64,
|
||||||
|
downloaded_bytes: u64,
|
||||||
|
progress_percent: f64,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> TorrentJobDto {
|
||||||
|
TorrentJobDto {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
info_hash: info_hash.to_string(),
|
||||||
|
status: status.as_str().to_string(),
|
||||||
|
total_size,
|
||||||
|
selected_size,
|
||||||
|
downloaded_bytes,
|
||||||
|
progress_percent: progress_percent.clamp(0.0, 100.0),
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TorrentService {
|
||||||
|
temp_root: PathBuf,
|
||||||
|
session: OnceCell<Arc<Session>>,
|
||||||
|
jobs: Mutex<HashMap<String, TorrentJob>>,
|
||||||
|
scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrentService {
|
||||||
|
pub fn new(scheduler_handle: Arc<OnceCell<Arc<SchedulerHandle>>>) -> Self {
|
||||||
|
Self {
|
||||||
|
temp_root: std::env::temp_dir().join("furumusic").join("torrents"),
|
||||||
|
session: OnceCell::new(),
|
||||||
|
jobs: Mutex::new(HashMap::new()),
|
||||||
|
scheduler_handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session(&self) -> anyhow::Result<Arc<Session>> {
|
||||||
|
let temp_root = self.temp_root.clone();
|
||||||
|
self.session
|
||||||
|
.get_or_try_init(|| async move {
|
||||||
|
tokio::fs::create_dir_all(&temp_root).await?;
|
||||||
|
Session::new_with_opts(
|
||||||
|
temp_root,
|
||||||
|
SessionOptions {
|
||||||
|
disable_upload: true,
|
||||||
|
enable_upnp_port_forwarding: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
&self,
|
||||||
|
request: TorrentPreviewRequest,
|
||||||
|
) -> anyhow::Result<TorrentPreviewDto> {
|
||||||
|
let session = self.session().await?;
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let output_dir = self.temp_root.join(&id).join("download");
|
||||||
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
|
let add = match request.kind {
|
||||||
|
TorrentPreviewKind::Magnet => {
|
||||||
|
let magnet = request
|
||||||
|
.magnet
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.context("magnet link is empty")?;
|
||||||
|
AddTorrent::from_url(magnet.to_string())
|
||||||
|
}
|
||||||
|
TorrentPreviewKind::TorrentFile => {
|
||||||
|
let encoded = request
|
||||||
|
.torrent_base64
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.context("torrent file is empty")?;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.context("invalid torrent file encoding")?;
|
||||||
|
AddTorrent::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
METADATA_TIMEOUT,
|
||||||
|
session.add_torrent(
|
||||||
|
add,
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
list_only: true,
|
||||||
|
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("timed out while resolving torrent metadata")??;
|
||||||
|
|
||||||
|
let AddTorrentResponse::ListOnly(list) = response else {
|
||||||
|
bail!("torrent was unexpectedly added instead of previewed");
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = list
|
||||||
|
.info
|
||||||
|
.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| String::from_utf8_lossy(b.as_ref()).to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| list.info_hash.as_string());
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for (index, details) in list.info.iter_file_details()?.enumerate() {
|
||||||
|
let name = details
|
||||||
|
.filename
|
||||||
|
.to_string()
|
||||||
|
.unwrap_or_else(|_| "<invalid filename>".to_string());
|
||||||
|
let selected = is_audio_path(&name);
|
||||||
|
files.push(TorrentFileDto {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
components: details.filename.to_vec().unwrap_or_default(),
|
||||||
|
length: details.len,
|
||||||
|
selected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = files.iter().map(|f| f.length).sum();
|
||||||
|
let dto = TorrentPreviewDto {
|
||||||
|
id: id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
info_hash: list.info_hash.as_string(),
|
||||||
|
total_size,
|
||||||
|
files: files.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let job = TorrentJob {
|
||||||
|
id: id.clone(),
|
||||||
|
name,
|
||||||
|
info_hash: dto.info_hash.clone(),
|
||||||
|
torrent_bytes: list.torrent_bytes.to_vec(),
|
||||||
|
files,
|
||||||
|
status: TorrentJobStatus::Preview,
|
||||||
|
output_dir,
|
||||||
|
selected_files: Vec::new(),
|
||||||
|
handle: None,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
self.jobs.lock().await.insert(id, job);
|
||||||
|
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self, id: &str) -> anyhow::Result<TorrentJobDto> {
|
||||||
|
let jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get(id).context("torrent job not found")?;
|
||||||
|
Ok(job.dto())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
id: &str,
|
||||||
|
selected_files: Vec<usize>,
|
||||||
|
inbox_dir: String,
|
||||||
|
) -> anyhow::Result<TorrentJobDto> {
|
||||||
|
if selected_files.is_empty() {
|
||||||
|
bail!("select at least one file");
|
||||||
|
}
|
||||||
|
if inbox_dir.trim().is_empty() {
|
||||||
|
bail!("agent_inbox_dir is not configured");
|
||||||
|
}
|
||||||
|
let inbox_dir = validate_inbox_dir(&inbox_dir)?;
|
||||||
|
|
||||||
|
let (torrent_bytes, output_dir) = {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
if job.status != TorrentJobStatus::Preview && job.status != TorrentJobStatus::Failed {
|
||||||
|
bail!("torrent job is already started");
|
||||||
|
}
|
||||||
|
validate_selection(&job.files, &selected_files)?;
|
||||||
|
job.status = TorrentJobStatus::Downloading;
|
||||||
|
job.selected_files = selected_files.clone();
|
||||||
|
job.error = None;
|
||||||
|
(job.torrent_bytes.clone(), job.output_dir.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = self.session().await?;
|
||||||
|
let response = session
|
||||||
|
.add_torrent(
|
||||||
|
AddTorrent::from_bytes(torrent_bytes),
|
||||||
|
Some(AddTorrentOptions {
|
||||||
|
only_files: Some(selected_files),
|
||||||
|
output_folder: Some(output_dir.to_string_lossy().to_string()),
|
||||||
|
overwrite: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let handle = response
|
||||||
|
.into_handle()
|
||||||
|
.context("torrent did not return a download handle")?;
|
||||||
|
|
||||||
|
let dto = {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
job.handle = Some(handle.clone());
|
||||||
|
job.dto()
|
||||||
|
};
|
||||||
|
|
||||||
|
let service = Arc::clone(self);
|
||||||
|
let id = id.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle.wait_until_completed().await {
|
||||||
|
service.stop_torrent(&handle).await;
|
||||||
|
service.fail_job(&id, err.to_string()).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.stop_torrent(&handle).await;
|
||||||
|
if let Err(err) = service.finalize_completed(&id, &inbox_dir).await {
|
||||||
|
service.fail_job(&id, err.to_string()).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_job(&self, id: &str, error: String) {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
if let Some(job) = jobs.get_mut(id) {
|
||||||
|
job.status = TorrentJobStatus::Failed;
|
||||||
|
job.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop_torrent(&self, handle: &Arc<ManagedTorrent>) {
|
||||||
|
match self.session().await {
|
||||||
|
Ok(session) => {
|
||||||
|
if let Err(err) = session.delete(handle.id().into(), false).await {
|
||||||
|
tracing::warn!("failed to stop completed torrent: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!("failed to access torrent session for shutdown: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finalize_completed(&self, id: &str, inbox_dir: &Path) -> anyhow::Result<()> {
|
||||||
|
let (name, files, selected_files, output_dir) = {
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
job.status = TorrentJobStatus::Moving;
|
||||||
|
(
|
||||||
|
job.name.clone(),
|
||||||
|
job.files.clone(),
|
||||||
|
job.selected_files.clone(),
|
||||||
|
job.output_dir.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let destination_root = inbox_dir
|
||||||
|
.join("torrents")
|
||||||
|
.join(sanitize_path_component(&name));
|
||||||
|
tokio::fs::create_dir_all(&destination_root).await?;
|
||||||
|
|
||||||
|
for file in files.iter().filter(|f| selected_files.contains(&f.index)) {
|
||||||
|
let source = safe_join(&output_dir, &file.components)?;
|
||||||
|
if !tokio::fs::try_exists(&source).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let destination = safe_join(&destination_root, &file.components)?;
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
move_file(&source, &destination).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let job_root = self.temp_root.join(id);
|
||||||
|
let _ = tokio::fs::remove_dir_all(job_root).await;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut jobs = self.jobs.lock().await;
|
||||||
|
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||||
|
job.status = TorrentJobStatus::Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = self.scheduler_handle.get() {
|
||||||
|
let handle = Arc::clone(handle);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle.trigger_job_now("inbox_discover").await {
|
||||||
|
tracing::warn!("failed to trigger inbox_discover after torrent: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_selection(files: &[TorrentFileDto], selected_files: &[usize]) -> anyhow::Result<()> {
|
||||||
|
for index in selected_files {
|
||||||
|
if !files.iter().any(|file| file.index == *index) {
|
||||||
|
bail!("selected file index {index} is not in this torrent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_inbox_dir(inbox_dir: &str) -> anyhow::Result<PathBuf> {
|
||||||
|
let trimmed = inbox_dir.trim();
|
||||||
|
let path = PathBuf::from(trimmed);
|
||||||
|
if !path.is_absolute() {
|
||||||
|
bail!(
|
||||||
|
"agent_inbox_dir must be an absolute path for this host, got `{}`",
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_audio_path(path: &str) -> bool {
|
||||||
|
let Some(ext) = Path::new(path).extension().and_then(|e| e.to_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
ext.to_ascii_lowercase().as_str(),
|
||||||
|
"mp3"
|
||||||
|
| "flac"
|
||||||
|
| "ogg"
|
||||||
|
| "opus"
|
||||||
|
| "aac"
|
||||||
|
| "m4a"
|
||||||
|
| "wav"
|
||||||
|
| "ape"
|
||||||
|
| "wv"
|
||||||
|
| "wma"
|
||||||
|
| "tta"
|
||||||
|
| "aiff"
|
||||||
|
| "aif"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path_component(value: &str) -> String {
|
||||||
|
let sanitized: String = value
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = sanitized.trim().trim_matches('.').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"torrent".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_join(root: &Path, components: &[String]) -> anyhow::Result<PathBuf> {
|
||||||
|
let mut path = root.to_path_buf();
|
||||||
|
for component in components {
|
||||||
|
let sanitized = sanitize_path_component(component);
|
||||||
|
if sanitized == "." || sanitized == ".." {
|
||||||
|
bail!("unsafe torrent path component");
|
||||||
|
}
|
||||||
|
path.push(sanitized);
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_file(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
||||||
|
match tokio::fs::rename(source, destination).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
|
||||||
|
tokio::fs::copy(source, destination).await?;
|
||||||
|
tokio::fs::remove_file(source).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
+755
-5
@@ -938,15 +938,29 @@ body {
|
|||||||
.empty-state p { font-size: 14px; }
|
.empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
.content-topbar {
|
.content-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search bar */
|
/* Search bar */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-account-chip {
|
.mobile-account-chip {
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -966,6 +980,31 @@ body {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-import-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-import-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-import-btn svg {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-account-chip .user-avatar {
|
.mobile-account-chip .user-avatar {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -1238,6 +1277,216 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-modal {
|
||||||
|
width: min(860px, calc(100vw - 32px));
|
||||||
|
max-width: 860px;
|
||||||
|
max-height: min(88dvh, 760px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal input[type="file"],
|
||||||
|
.torrent-modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal input[type="file"]:focus,
|
||||||
|
.torrent-modal textarea:focus {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-message {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-message.error { color: #ff8b8b; }
|
||||||
|
|
||||||
|
.torrent-preview-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-preview-title {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-preview-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-preview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-selected-summary {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-file-tree {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: min(46vh, 420px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 24px minmax(0, 1fr) 92px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 10px 7px var(--indent, 10px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-row:last-child { border-bottom: 0; }
|
||||||
|
.torrent-tree-row:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
.torrent-tree-toggle,
|
||||||
|
.torrent-tree-check {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-toggle svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-toggle.expanded svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-check {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-check.checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-check.partial {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-check svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-label {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-label svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-file-size {
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sidebar playlist actions */
|
/* Sidebar playlist actions */
|
||||||
.playlist-item-row {
|
.playlist-item-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1322,9 +1571,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-topbar {
|
.content-topbar {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,6 +1583,14 @@ body {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -1622,15 +1876,74 @@ body {
|
|||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-box {
|
.modal-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: min(76dvh, 560px);
|
max-height: calc(100dvh - 16px);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal {
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-modal textarea {
|
||||||
|
min-height: 68px;
|
||||||
|
max-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-preview-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-file-tree {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: min(32dvh, 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-tree-row {
|
||||||
|
grid-template-columns: 24px 22px minmax(0, 1fr) 74px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-file-size {
|
||||||
|
grid-column: 4;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-panel {
|
.queue-panel {
|
||||||
@@ -1785,6 +2098,16 @@ body {
|
|||||||
<span class="search-shortcut">Ctrl+K</span>
|
<span class="search-shortcut">Ctrl+K</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="torrent-import-btn"
|
||||||
|
@click="$store.torrents.open()"
|
||||||
|
title="Import torrent">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="version-chip">v{{ t.app_version() }}</span>
|
||||||
<button class="mobile-account-chip"
|
<button class="mobile-account-chip"
|
||||||
x-show="$store.user.profile"
|
x-show="$store.user.profile"
|
||||||
x-cloak
|
x-cloak
|
||||||
@@ -2301,6 +2624,101 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Torrent Import Modal -->
|
||||||
|
<template x-if="$store.torrents.modal">
|
||||||
|
<div class="modal-overlay" @click.self="$store.torrents.close()">
|
||||||
|
<div class="modal-box torrent-modal">
|
||||||
|
<h3>Import torrent</h3>
|
||||||
|
<div class="torrent-modal-grid">
|
||||||
|
<div>
|
||||||
|
<label for="torrent-file-input">Torrent file</label>
|
||||||
|
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
|
||||||
|
@change="$store.torrents.file = $event.target.files[0] || null">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="torrent-magnet-input">Magnet link</label>
|
||||||
|
<textarea id="torrent-magnet-input" x-model="$store.torrents.magnet" placeholder="magnet:?xt=urn:btih:..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="torrent-message" :class="{ error: $store.torrents.error }"
|
||||||
|
x-text="$store.torrents.message"></p>
|
||||||
|
<div class="torrent-actions">
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
|
||||||
|
Preview content
|
||||||
|
</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAudio()" :disabled="!$store.torrents.previewData">Audio only</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAll(true)" :disabled="!$store.torrents.previewData">All</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.selectAll(false)" :disabled="!$store.torrents.previewData">Clear</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="$store.torrents.previewData">
|
||||||
|
<div class="torrent-preview-panel">
|
||||||
|
<div class="torrent-preview-head">
|
||||||
|
<div style="min-width:0">
|
||||||
|
<div class="torrent-preview-title" x-text="$store.torrents.previewData.name"></div>
|
||||||
|
<div class="torrent-preview-meta"
|
||||||
|
x-text="$store.torrents.previewData.files.length + ' files · ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-btn modal-btn-primary" @click="$store.torrents.start()" :disabled="$store.torrents.loading">
|
||||||
|
Download selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-tree-toolbar">
|
||||||
|
<div class="torrent-selected-summary"
|
||||||
|
x-text="$store.torrents.selected.size + ' selected · ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
|
||||||
|
<div class="torrent-actions" style="margin-top:0">
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(true)">Expand all</button>
|
||||||
|
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(false)">Collapse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="torrent-file-tree">
|
||||||
|
<template x-for="node in $store.torrents.visibleNodes()" :key="node.key">
|
||||||
|
<div class="torrent-tree-row" :style="'--indent:' + $store.torrents.rowIndent(node) + 'px'">
|
||||||
|
<button class="torrent-tree-toggle"
|
||||||
|
:class="{ expanded: $store.torrents.expanded.has(node.key) }"
|
||||||
|
@click="$store.torrents.toggleExpand(node)"
|
||||||
|
:style="node.type === 'folder' ? '' : 'visibility:hidden'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="torrent-tree-check"
|
||||||
|
:class="$store.torrents.nodeCheckClass(node)"
|
||||||
|
@click="$store.torrents.toggleNode(node)">
|
||||||
|
<template x-if="$store.torrents.nodeState(node) === 'checked'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="$store.torrents.nodeState(node) === 'partial'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<div class="torrent-tree-label" :title="node.name">
|
||||||
|
<template x-if="node.type === 'folder'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="node.type === 'file'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<span class="torrent-file-name" x-text="node.name"></span>
|
||||||
|
</div>
|
||||||
|
<span class="torrent-file-size" x-text="$store.torrents.bytes(node.size)"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
||||||
@@ -3028,6 +3446,338 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Torrent import store
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
Alpine.store('torrents', {
|
||||||
|
modal: false,
|
||||||
|
file: null,
|
||||||
|
magnet: '',
|
||||||
|
previewData: null,
|
||||||
|
treeRoot: null,
|
||||||
|
selected: new Set(),
|
||||||
|
expanded: new Set(),
|
||||||
|
loading: false,
|
||||||
|
message: '',
|
||||||
|
error: false,
|
||||||
|
_pollTimer: null,
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.modal = true;
|
||||||
|
this.message = '';
|
||||||
|
this.error = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modal = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setMessage(message, error = false) {
|
||||||
|
this.message = message || '';
|
||||||
|
this.error = error;
|
||||||
|
},
|
||||||
|
|
||||||
|
bytes(value) {
|
||||||
|
if (!value) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let size = Number(value);
|
||||||
|
let idx = 0;
|
||||||
|
while (size >= 1024 && idx < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
|
||||||
|
},
|
||||||
|
|
||||||
|
async _fileBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = String(reader.result || '');
|
||||||
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async preview() {
|
||||||
|
if (this.loading) return;
|
||||||
|
const magnet = this.magnet.trim();
|
||||||
|
if (!this.file && !magnet) {
|
||||||
|
this._setMessage('Choose a .torrent file or paste a magnet link.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.previewData = null;
|
||||||
|
this.treeRoot = null;
|
||||||
|
this.selected = new Set();
|
||||||
|
this.expanded = new Set();
|
||||||
|
this._setMessage(this.file ? 'Reading torrent file...' : 'Resolving magnet metadata. This can take a while...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.file
|
||||||
|
? { kind: 'torrent_file', torrent_base64: await this._fileBase64(this.file) }
|
||||||
|
: { kind: 'magnet', magnet };
|
||||||
|
const res = await fetch('/api/player/torrents/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Preview failed');
|
||||||
|
|
||||||
|
this.previewData = data;
|
||||||
|
this.selected = new Set(data.files.filter(f => f.selected).map(f => f.index));
|
||||||
|
this.treeRoot = this._buildTree(data.files || []);
|
||||||
|
this._setMessage('Choose files and start download.');
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || String(err), true);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(index, checked) {
|
||||||
|
const next = new Set(this.selected);
|
||||||
|
if (checked) next.add(index);
|
||||||
|
else next.delete(index);
|
||||||
|
this.selected = next;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll(value) {
|
||||||
|
if (!this.previewData) return;
|
||||||
|
this.selected = value
|
||||||
|
? new Set(this.previewData.files.map(f => f.index))
|
||||||
|
: new Set();
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAudio() {
|
||||||
|
if (!this.previewData) return;
|
||||||
|
this.selected = new Set(
|
||||||
|
this.previewData.files
|
||||||
|
.filter(f => /\.(mp3|flac|ogg|opus|aac|m4a|wav|ape|wv|wma|tta|aiff|aif)$/i.test(f.name))
|
||||||
|
.map(f => f.index)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
selectedBytes() {
|
||||||
|
if (!this.previewData) return 0;
|
||||||
|
return this.previewData.files
|
||||||
|
.filter(file => this.selected.has(file.index))
|
||||||
|
.reduce((sum, file) => sum + Number(file.length || 0), 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
_buildTree(files) {
|
||||||
|
const root = {
|
||||||
|
type: 'folder',
|
||||||
|
key: 'root',
|
||||||
|
name: 'root',
|
||||||
|
depth: -1,
|
||||||
|
size: 0,
|
||||||
|
fileIndexes: [],
|
||||||
|
children: [],
|
||||||
|
childMap: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureFolder = (parent, name, key, depth) => {
|
||||||
|
let node = parent.childMap.get(key);
|
||||||
|
if (!node) {
|
||||||
|
node = {
|
||||||
|
type: 'folder',
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
depth,
|
||||||
|
size: 0,
|
||||||
|
fileIndexes: [],
|
||||||
|
children: [],
|
||||||
|
childMap: new Map(),
|
||||||
|
};
|
||||||
|
parent.childMap.set(key, node);
|
||||||
|
parent.children.push(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const components = file.components && file.components.length
|
||||||
|
? file.components
|
||||||
|
: String(file.name || '').split('/').filter(Boolean);
|
||||||
|
const pathParts = components.length ? components : [file.name || ('file-' + file.index)];
|
||||||
|
let parent = root;
|
||||||
|
const folderChain = [root];
|
||||||
|
|
||||||
|
pathParts.slice(0, -1).forEach((part, depth) => {
|
||||||
|
const key = parent.key + '/' + part;
|
||||||
|
parent = ensureFolder(parent, part, key, depth);
|
||||||
|
folderChain.push(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileNode = {
|
||||||
|
type: 'file',
|
||||||
|
key: 'file:' + file.index,
|
||||||
|
name: pathParts[pathParts.length - 1],
|
||||||
|
depth: Math.max(pathParts.length - 1, 0),
|
||||||
|
size: Number(file.length || 0),
|
||||||
|
fileIndex: file.index,
|
||||||
|
fileIndexes: [file.index],
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
parent.children.push(fileNode);
|
||||||
|
|
||||||
|
folderChain.forEach(folder => {
|
||||||
|
folder.size += fileNode.size;
|
||||||
|
folder.fileIndexes.push(file.index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortAndSeal = node => {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
node.children.forEach(sortAndSeal);
|
||||||
|
delete node.childMap;
|
||||||
|
};
|
||||||
|
sortAndSeal(root);
|
||||||
|
|
||||||
|
const initiallyExpanded = new Set();
|
||||||
|
const collectExpanded = node => {
|
||||||
|
if (node.type === 'folder' && node.depth < 1) initiallyExpanded.add(node.key);
|
||||||
|
node.children.forEach(collectExpanded);
|
||||||
|
};
|
||||||
|
collectExpanded(root);
|
||||||
|
this.expanded = initiallyExpanded;
|
||||||
|
|
||||||
|
return root;
|
||||||
|
},
|
||||||
|
|
||||||
|
visibleNodes() {
|
||||||
|
if (!this.treeRoot) return [];
|
||||||
|
const rows = [];
|
||||||
|
const visit = node => {
|
||||||
|
node.children.forEach(child => {
|
||||||
|
rows.push(child);
|
||||||
|
if (child.type === 'folder' && this.expanded.has(child.key)) {
|
||||||
|
visit(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
visit(this.treeRoot);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
rowIndent(node) {
|
||||||
|
return Math.min(10 + Math.max(node.depth, 0) * 18, 82);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleExpand(node) {
|
||||||
|
if (node.type !== 'folder') return;
|
||||||
|
const next = new Set(this.expanded);
|
||||||
|
if (next.has(node.key)) next.delete(node.key);
|
||||||
|
else next.add(node.key);
|
||||||
|
this.expanded = next;
|
||||||
|
},
|
||||||
|
|
||||||
|
expandAll(value) {
|
||||||
|
if (!this.treeRoot) return;
|
||||||
|
const next = new Set();
|
||||||
|
const visit = node => {
|
||||||
|
if (node.type === 'folder' && value) next.add(node.key);
|
||||||
|
node.children.forEach(visit);
|
||||||
|
};
|
||||||
|
visit(this.treeRoot);
|
||||||
|
this.expanded = next;
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeState(node) {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
return this.selected.has(node.fileIndex) ? 'checked' : 'empty';
|
||||||
|
}
|
||||||
|
const total = node.fileIndexes.length;
|
||||||
|
const selected = node.fileIndexes.filter(index => this.selected.has(index)).length;
|
||||||
|
if (selected === 0) return 'empty';
|
||||||
|
if (selected === total) return 'checked';
|
||||||
|
return 'partial';
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeCheckClass(node) {
|
||||||
|
const state = this.nodeState(node);
|
||||||
|
return {
|
||||||
|
checked: state === 'checked',
|
||||||
|
partial: state === 'partial',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleNode(node) {
|
||||||
|
const next = new Set(this.selected);
|
||||||
|
if (node.type === 'file') {
|
||||||
|
if (next.has(node.fileIndex)) next.delete(node.fileIndex);
|
||||||
|
else next.add(node.fileIndex);
|
||||||
|
this.selected = next;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.nodeState(node) === 'checked') {
|
||||||
|
node.fileIndexes.forEach(index => next.delete(index));
|
||||||
|
} else {
|
||||||
|
node.fileIndexes.forEach(index => next.add(index));
|
||||||
|
}
|
||||||
|
this.selected = next;
|
||||||
|
},
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (!this.previewData || this.loading) return;
|
||||||
|
const selected = [...this.selected];
|
||||||
|
if (selected.length === 0) {
|
||||||
|
this._setMessage('Select at least one file.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this._setMessage('Starting download...');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ selected_files: selected }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Start failed');
|
||||||
|
this._setMessage('Download started. Files will move to inbox when complete.');
|
||||||
|
this._poll(data.id);
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || String(err), true);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_poll(id) {
|
||||||
|
if (this._pollTimer) clearInterval(this._pollTimer);
|
||||||
|
this._pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/torrents/${id}/status`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Status failed');
|
||||||
|
this._setMessage(
|
||||||
|
data.status + ' · ' + data.progress_percent.toFixed(1) + '% · ' + this.bytes(data.downloaded_bytes),
|
||||||
|
data.status === 'failed'
|
||||||
|
);
|
||||||
|
if (data.status === 'complete' || data.status === 'failed') {
|
||||||
|
clearInterval(this._pollTimer);
|
||||||
|
this._pollTimer = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._setMessage(err.message || String(err), true);
|
||||||
|
clearInterval(this._pollTimer);
|
||||||
|
this._pollTimer = null;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Playlists store
|
// Playlists store
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user