2026-05-23 13:08:09 +03:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use cot::db::Database;
|
|
|
|
|
use cot::http::StatusCode;
|
2026-05-25 13:50:24 +03:00
|
|
|
use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
|
2026-05-23 13:08:09 +03:00
|
|
|
use cot::json::Json;
|
|
|
|
|
use cot::request::extractors::Path;
|
|
|
|
|
use cot::response::IntoResponse;
|
|
|
|
|
use cot::router::method::{get, post};
|
|
|
|
|
use cot::router::{Route, Router};
|
|
|
|
|
use cot::session::Session;
|
|
|
|
|
use cot::{App, Body, Template};
|
|
|
|
|
|
|
|
|
|
use crate::auth;
|
|
|
|
|
use crate::config::AppConfig;
|
|
|
|
|
use crate::i18n::Translations;
|
2026-05-25 15:40:07 +03:00
|
|
|
use crate::scheduler::SchedulerHandle;
|
|
|
|
|
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
|
2026-05-23 13:08:09 +03:00
|
|
|
|
2026-05-25 23:04:58 +03:00
|
|
|
mod dto;
|
|
|
|
|
mod helpers;
|
|
|
|
|
mod queries;
|
|
|
|
|
mod rows;
|
|
|
|
|
|
|
|
|
|
use dto::*;
|
|
|
|
|
use helpers::{cover_url, load_release_uploaders, track_cover_url};
|
|
|
|
|
use queries::*;
|
|
|
|
|
use rows::*;
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// JSON error helper
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
|
|
|
|
|
let body = serde_json::json!({ "error": message });
|
|
|
|
|
cot::http::Response::builder()
|
|
|
|
|
.status(status)
|
|
|
|
|
.header(CONTENT_TYPE, "application/json")
|
|
|
|
|
.body(Body::fixed(body.to_string()))
|
|
|
|
|
.expect("valid response")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// SPA shell
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Template)]
|
|
|
|
|
#[template(path = "player.html")]
|
|
|
|
|
pub struct PlayerPageTemplate {
|
|
|
|
|
pub t: &'static Translations,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 14:42:25 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/me
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn me_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let liked_tracks: (i64,) =
|
|
|
|
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let playlists: (i64,) =
|
|
|
|
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__playlist WHERE owner_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let plays: (i64,) =
|
|
|
|
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let listened_seconds: Option<i64> = sqlx::query_scalar(
|
|
|
|
|
"SELECT COALESCE(SUM(duration_listened), 0) FROM furumusic__play_history WHERE user_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(UserProfile {
|
|
|
|
|
name: user.name,
|
|
|
|
|
role: user.role.code().to_string(),
|
|
|
|
|
stats: UserStats {
|
|
|
|
|
liked_tracks: liked_tracks.0,
|
|
|
|
|
playlists: playlists.0,
|
|
|
|
|
plays: plays.0,
|
|
|
|
|
listened_minutes: listened_seconds.unwrap_or(0) / 60,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 14:47:10 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/agent-queue
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn agent_queue_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (queued_count, processing_count): (i64, i64) = sqlx::query_as(
|
|
|
|
|
r#"SELECT
|
|
|
|
|
COUNT(*) FILTER (WHERE status = 'queued') AS queued_count,
|
|
|
|
|
COUNT(*) FILTER (WHERE status = 'processing') AS processing_count
|
|
|
|
|
FROM furumusic__pending_review"#,
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(AgentQueueStatus {
|
|
|
|
|
queued_count,
|
|
|
|
|
processing_count,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/artists?page=N&limit=N
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn artists_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<PaginationQuery>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let page = query.0.page.unwrap_or(1).max(1);
|
|
|
|
|
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
|
|
|
|
|
let offset = (page - 1) as i64 * per_page as i64;
|
|
|
|
|
|
|
|
|
|
let total_row = sqlx::query_as::<_, CountRow>(
|
2026-05-25 14:30:33 +03:00
|
|
|
r#"SELECT COUNT(DISTINCT a.id) AS count
|
|
|
|
|
FROM furumusic__artist a
|
|
|
|
|
JOIN furumusic__release_artist ra ON ra.artist_id = a.id
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id
|
2026-05-25 15:57:10 +03:00
|
|
|
WHERE a.is_hidden = false AND r.is_hidden = false AND ra.position = 0"#,
|
2026-05-23 13:08:09 +03:00
|
|
|
)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query_as::<_, ArtistRow>(
|
|
|
|
|
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
2026-05-25 14:30:33 +03:00
|
|
|
s.release_count,
|
|
|
|
|
s.track_count
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__artist a
|
2026-05-25 14:30:33 +03:00
|
|
|
JOIN (
|
|
|
|
|
SELECT ra.artist_id,
|
|
|
|
|
COUNT(DISTINCT r.id) AS release_count,
|
|
|
|
|
COUNT(t.id) AS track_count
|
|
|
|
|
FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
|
|
|
|
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
2026-05-25 15:57:10 +03:00
|
|
|
WHERE ra.position = 0
|
2026-05-25 14:30:33 +03:00
|
|
|
GROUP BY ra.artist_id
|
|
|
|
|
) s ON s.artist_id = a.id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE a.is_hidden = false
|
2026-05-25 14:30:33 +03:00
|
|
|
ORDER BY s.release_count DESC, s.track_count DESC, a.name_sort
|
2026-05-23 13:08:09 +03:00
|
|
|
LIMIT $1 OFFSET $2"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(per_page as i64)
|
|
|
|
|
.bind(offset)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let items: Vec<ArtistCard> = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| ArtistCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
image_url: cover_url(r.image_file_id),
|
|
|
|
|
release_count: r.release_count,
|
2026-05-25 14:30:33 +03:00
|
|
|
track_count: r.track_count,
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Json(Paginated {
|
|
|
|
|
items,
|
|
|
|
|
total: total_row.count,
|
|
|
|
|
page,
|
|
|
|
|
per_page,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/artists/{id}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn artist_detail_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let artist_id = path.0.id;
|
|
|
|
|
|
|
|
|
|
let artist = sqlx::query_as::<_, ArtistBriefRow>(
|
|
|
|
|
"SELECT id, name::text as name FROM furumusic__artist WHERE id = $1 AND is_hidden = false",
|
|
|
|
|
)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let Some(artist) = artist else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-25 13:50:24 +03:00
|
|
|
let image_file_id: Option<i64> =
|
|
|
|
|
sqlx::query_scalar("SELECT image_file_id FROM furumusic__artist WHERE id = $1")
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
let releases = sqlx::query_as::<_, ReleaseRow>(
|
|
|
|
|
r#"SELECT r.id, r.title::text as title, r.release_type::text as release_type,
|
|
|
|
|
r.year, r.cover_file_id,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) as track_count
|
|
|
|
|
FROM furumusic__release r
|
|
|
|
|
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
|
|
|
|
WHERE ra.artist_id = $1 AND r.is_hidden = false
|
|
|
|
|
ORDER BY r.year DESC NULLS LAST, r.title_sort"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
2026-05-25 23:04:58 +03:00
|
|
|
let release_ids: Vec<i64> = releases.iter().map(|r| r.id).collect();
|
|
|
|
|
let mut release_uploaders = load_release_uploaders(pool, &release_ids)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
let release_cards: Vec<ReleaseCard> = releases
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| ReleaseCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
title: r.title,
|
|
|
|
|
release_type: r.release_type,
|
|
|
|
|
year: r.year,
|
|
|
|
|
cover_url: cover_url(r.cover_file_id),
|
|
|
|
|
track_count: r.track_count,
|
2026-05-25 23:04:58 +03:00
|
|
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-05-25 13:50:24 +03:00
|
|
|
let total_track_count = release_cards.iter().map(|r| r.track_count).sum();
|
|
|
|
|
let total_play_count: i64 = sqlx::query_scalar(
|
|
|
|
|
r#"SELECT COUNT(*)
|
|
|
|
|
FROM furumusic__play_history ph
|
|
|
|
|
JOIN furumusic__track t ON t.id = ph.track_id
|
|
|
|
|
JOIN furumusic__release_artist ra ON ra.release_id = t.release_id
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
|
|
|
|
WHERE ra.artist_id = $1 AND t.is_hidden = false AND r.is_hidden = false"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
2026-05-25 16:26:45 +03:00
|
|
|
let featured_rows = sqlx::query_as::<_, AppearanceTrackRow>(
|
|
|
|
|
r#"SELECT DISTINCT t.id,
|
|
|
|
|
t.title::text AS title,
|
|
|
|
|
r.id AS release_id,
|
|
|
|
|
r.title::text AS release_title,
|
2026-05-26 11:15:27 +03:00
|
|
|
r.year AS release_year,
|
2026-05-25 16:26:45 +03:00
|
|
|
t.duration_seconds,
|
|
|
|
|
t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
r.cover_file_id AS release_cover_file_id,
|
|
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-25 16:26:45 +03:00
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__track t ON t.id = ta.track_id
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-25 16:26:45 +03:00
|
|
|
WHERE ta.artist_id = $1
|
|
|
|
|
AND ta.role = 'featuring'
|
|
|
|
|
AND t.is_hidden = false
|
|
|
|
|
AND r.is_hidden = false
|
|
|
|
|
ORDER BY r.title::text, t.title::text"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let featured_track_ids: Vec<i64> = featured_rows.iter().map(|t| t.id).collect();
|
|
|
|
|
let featured_track_artists = if featured_track_ids.is_empty() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
sqlx::query_as::<_, TrackArtistRow>(
|
|
|
|
|
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
|
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
|
|
|
|
WHERE ta.track_id = ANY($1)
|
|
|
|
|
ORDER BY ta.track_id, ta.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&featured_track_ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut featured_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
let mut featured_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for ta in &featured_track_artists {
|
|
|
|
|
let artist_ref = ArtistRef {
|
|
|
|
|
id: ta.artist_id,
|
|
|
|
|
name: ta.artist_name.clone(),
|
|
|
|
|
};
|
|
|
|
|
if ta.role == "featuring" {
|
|
|
|
|
featured_feat_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
} else {
|
|
|
|
|
featured_main_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let featured_tracks: Vec<ArtistAppearanceTrack> = featured_rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|t| {
|
|
|
|
|
let tid = t.id;
|
|
|
|
|
ArtistAppearanceTrack {
|
|
|
|
|
id: t.id,
|
|
|
|
|
title: t.title,
|
|
|
|
|
release_id: t.release_id,
|
|
|
|
|
release_title: t.release_title,
|
2026-05-26 11:15:27 +03:00
|
|
|
release_year: t.release_year,
|
2026-05-25 16:26:45 +03:00
|
|
|
duration_seconds: t.duration_seconds,
|
|
|
|
|
artists: featured_main_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
featured_artists: featured_feat_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
|
|
|
|
stream_url: format!("/api/player/stream/{tid}"),
|
2026-05-25 23:04:58 +03:00
|
|
|
uploader_name: t.uploader_name,
|
|
|
|
|
audio_format: t.audio_format,
|
|
|
|
|
audio_bitrate: t.audio_bitrate,
|
|
|
|
|
audio_sample_rate: t.audio_sample_rate,
|
|
|
|
|
audio_bit_depth: t.audio_bit_depth,
|
|
|
|
|
file_size_bytes: t.file_size_bytes,
|
2026-05-25 16:26:45 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
Json(ArtistDetail {
|
|
|
|
|
id: artist.id,
|
|
|
|
|
name: artist.name,
|
|
|
|
|
image_url: cover_url(image_file_id),
|
2026-05-25 13:50:24 +03:00
|
|
|
total_track_count,
|
|
|
|
|
total_play_count,
|
2026-05-23 13:08:09 +03:00
|
|
|
releases: release_cards,
|
2026-05-25 16:26:45 +03:00
|
|
|
featured_tracks,
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/releases/{id}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn release_detail_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let release_id = path.0.id;
|
|
|
|
|
|
|
|
|
|
let release = sqlx::query_as::<_, ReleaseInfoRow>(
|
|
|
|
|
r#"SELECT id, title::text as title, release_type::text as release_type, year, cover_file_id
|
|
|
|
|
FROM furumusic__release WHERE id = $1 AND is_hidden = false"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let Some(release) = release else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "release not found"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Release artists
|
|
|
|
|
let release_artists = sqlx::query_as::<_, ArtistBriefRow>(
|
|
|
|
|
r#"SELECT a.id, a.name::text as name
|
|
|
|
|
FROM furumusic__artist a
|
|
|
|
|
JOIN furumusic__release_artist ra ON ra.artist_id = a.id
|
|
|
|
|
WHERE ra.release_id = $1
|
|
|
|
|
ORDER BY ra.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// Tracks
|
|
|
|
|
let tracks = sqlx::query_as::<_, TrackRow>(
|
|
|
|
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
r.cover_file_id as release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
r.year as release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__track t
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE t.release_id = $1 AND t.is_hidden = false
|
|
|
|
|
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let track_ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
|
|
|
|
|
|
|
|
|
|
// Track artists (batch)
|
|
|
|
|
let track_artists = if track_ids.is_empty() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
sqlx::query_as::<_, TrackArtistRow>(
|
|
|
|
|
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
|
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
|
|
|
|
WHERE ta.track_id = ANY($1)
|
|
|
|
|
ORDER BY ta.track_id, ta.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&track_ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Group track artists
|
|
|
|
|
let mut track_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
let mut track_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for ta in &track_artists {
|
|
|
|
|
let artist_ref = ArtistRef {
|
|
|
|
|
id: ta.artist_id,
|
|
|
|
|
name: ta.artist_name.clone(),
|
|
|
|
|
};
|
|
|
|
|
if ta.role == "featuring" {
|
|
|
|
|
track_feat_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
} else {
|
|
|
|
|
track_main_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let track_items: Vec<TrackItem> = tracks
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|t| {
|
|
|
|
|
let tid = t.id;
|
|
|
|
|
TrackItem {
|
|
|
|
|
id: t.id,
|
|
|
|
|
title: t.title,
|
|
|
|
|
track_number: t.track_number,
|
|
|
|
|
disc_number: t.disc_number,
|
|
|
|
|
duration_seconds: t.duration_seconds,
|
|
|
|
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
2026-05-26 11:15:27 +03:00
|
|
|
release_year: t.release_year,
|
2026-05-23 13:08:09 +03:00
|
|
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
|
|
|
|
stream_url: format!("/api/player/stream/{tid}"),
|
2026-05-25 23:04:58 +03:00
|
|
|
uploader_name: t.uploader_name,
|
|
|
|
|
audio_format: t.audio_format,
|
|
|
|
|
audio_bitrate: t.audio_bitrate,
|
|
|
|
|
audio_sample_rate: t.audio_sample_rate,
|
|
|
|
|
audio_bit_depth: t.audio_bit_depth,
|
|
|
|
|
file_size_bytes: t.file_size_bytes,
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
2026-05-25 23:04:58 +03:00
|
|
|
let uploaders = load_release_uploaders(pool, &[release.id])
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
.remove(&release.id)
|
|
|
|
|
.unwrap_or_default();
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
Json(ReleaseDetail {
|
|
|
|
|
id: release.id,
|
|
|
|
|
title: release.title,
|
|
|
|
|
release_type: release.release_type,
|
|
|
|
|
year: release.year,
|
|
|
|
|
cover_url: cover_url(release.cover_file_id),
|
|
|
|
|
artists: release_artists
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|a| ArtistRef {
|
|
|
|
|
id: a.id,
|
|
|
|
|
name: a.name,
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
tracks: track_items,
|
2026-05-25 23:04:58 +03:00
|
|
|
uploaders,
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/playlists
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn playlists_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Count liked tracks for the virtual Likes playlist
|
2026-05-25 13:50:24 +03:00
|
|
|
let likes_count: (i64,) =
|
|
|
|
|
sqlx::query_as("SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
let mut cards = vec![PlaylistCard {
|
|
|
|
|
id: -1,
|
|
|
|
|
title: "Likes".to_string(),
|
|
|
|
|
track_count: likes_count.0,
|
|
|
|
|
is_own: true,
|
2026-05-26 00:19:11 +03:00
|
|
|
owner_name: None,
|
|
|
|
|
is_public: false,
|
|
|
|
|
is_saved: false,
|
2026-05-23 13:08:09 +03:00
|
|
|
kind: "likes".to_string(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query_as::<_, PlaylistRow>(
|
|
|
|
|
r#"SELECT p.id, p.title::text as title,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__playlist_track pt WHERE pt.playlist_id = p.id), 0) as track_count,
|
2026-05-26 00:19:11 +03:00
|
|
|
(p.owner_id = $1) as is_own,
|
|
|
|
|
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
|
|
|
|
|
p.is_public,
|
|
|
|
|
EXISTS (
|
|
|
|
|
SELECT 1 FROM furumusic__saved_playlist sp
|
|
|
|
|
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
|
|
|
|
) as is_saved
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__playlist p
|
2026-05-26 00:19:11 +03:00
|
|
|
JOIN furumusic__user u ON u.id = p.owner_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE p.owner_id = $1
|
2026-05-26 00:19:11 +03:00
|
|
|
OR EXISTS (
|
|
|
|
|
SELECT 1 FROM furumusic__saved_playlist sp
|
|
|
|
|
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
|
|
|
|
)
|
2026-05-23 13:08:09 +03:00
|
|
|
OR p.is_public = true
|
2026-05-26 00:19:11 +03:00
|
|
|
ORDER BY
|
|
|
|
|
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
|
|
|
|
|
p.title"#,
|
2026-05-23 13:08:09 +03:00
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
cards.extend(rows.into_iter().map(|r| PlaylistCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
title: r.title,
|
|
|
|
|
track_count: r.track_count,
|
|
|
|
|
is_own: r.is_own,
|
2026-05-26 00:19:11 +03:00
|
|
|
owner_name: Some(r.owner_name),
|
|
|
|
|
is_public: r.is_public,
|
|
|
|
|
is_saved: r.is_saved,
|
2026-05-23 13:08:09 +03:00
|
|
|
kind: "user".to_string(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
Json(cards).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/playlists/{id}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn playlist_detail_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let playlist_id = path.0.id;
|
|
|
|
|
|
|
|
|
|
// Virtual Likes playlist (id = -1)
|
|
|
|
|
if playlist_id == -1 {
|
|
|
|
|
return likes_playlist_handler(user.id, pool).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let info = sqlx::query_as::<_, PlaylistInfoRow>(
|
2026-05-26 00:19:11 +03:00
|
|
|
r#"SELECT p.id, p.title::text as title, p.description, p.owner_id,
|
|
|
|
|
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
|
|
|
|
|
p.is_public,
|
|
|
|
|
EXISTS (
|
|
|
|
|
SELECT 1 FROM furumusic__saved_playlist sp
|
|
|
|
|
WHERE sp.user_id = $2 AND sp.playlist_id = p.id
|
|
|
|
|
) as is_saved
|
|
|
|
|
FROM furumusic__playlist p
|
|
|
|
|
JOIN furumusic__user u ON u.id = p.owner_id
|
|
|
|
|
WHERE p.id = $1"#,
|
2026-05-23 13:08:09 +03:00
|
|
|
)
|
|
|
|
|
.bind(playlist_id)
|
2026-05-26 00:19:11 +03:00
|
|
|
.bind(user.id)
|
2026-05-23 13:08:09 +03:00
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let Some(info) = info else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
|
|
|
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
r.cover_file_id as release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
r.year as release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__playlist_track pt
|
|
|
|
|
JOIN furumusic__track t ON t.id = pt.track_id
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
|
|
|
|
ORDER BY pt.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let track_items = build_track_items(tracks, pool).await?;
|
|
|
|
|
|
|
|
|
|
Json(PlaylistDetail {
|
|
|
|
|
id: info.id,
|
|
|
|
|
title: info.title,
|
|
|
|
|
description: info.description,
|
|
|
|
|
is_own: info.owner_id == user.id,
|
2026-05-26 00:19:11 +03:00
|
|
|
owner_name: Some(info.owner_name),
|
|
|
|
|
is_public: info.is_public,
|
|
|
|
|
is_saved: info.is_saved,
|
2026-05-23 13:08:09 +03:00
|
|
|
kind: "user".to_string(),
|
|
|
|
|
tracks: track_items,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shared helper: given PlaylistTrackRows, fetch artists and build TrackItems.
|
|
|
|
|
async fn build_track_items(
|
|
|
|
|
tracks: Vec<PlaylistTrackRow>,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<Vec<TrackItem>> {
|
|
|
|
|
let track_ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
|
|
|
|
|
|
|
|
|
|
let track_artists = if track_ids.is_empty() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
sqlx::query_as::<_, TrackArtistRow>(
|
|
|
|
|
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
|
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
|
|
|
|
WHERE ta.track_id = ANY($1)
|
|
|
|
|
ORDER BY ta.track_id, ta.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&track_ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut track_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
let mut track_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for ta in &track_artists {
|
|
|
|
|
let artist_ref = ArtistRef {
|
|
|
|
|
id: ta.artist_id,
|
|
|
|
|
name: ta.artist_name.clone(),
|
|
|
|
|
};
|
|
|
|
|
if ta.role == "featuring" {
|
|
|
|
|
track_feat_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
} else {
|
|
|
|
|
track_main_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(tracks
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|t| {
|
|
|
|
|
let tid = t.id;
|
|
|
|
|
TrackItem {
|
|
|
|
|
id: t.id,
|
|
|
|
|
title: t.title,
|
|
|
|
|
track_number: t.track_number,
|
|
|
|
|
disc_number: t.disc_number,
|
|
|
|
|
duration_seconds: t.duration_seconds,
|
|
|
|
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
2026-05-26 11:15:27 +03:00
|
|
|
release_year: t.release_year,
|
2026-05-23 13:08:09 +03:00
|
|
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
|
|
|
|
stream_url: format!("/api/player/stream/{tid}"),
|
2026-05-25 23:04:58 +03:00
|
|
|
uploader_name: t.uploader_name,
|
|
|
|
|
audio_format: t.audio_format,
|
|
|
|
|
audio_bitrate: t.audio_bitrate,
|
|
|
|
|
audio_sample_rate: t.audio_sample_rate,
|
|
|
|
|
audio_bit_depth: t.audio_bit_depth,
|
|
|
|
|
file_size_bytes: t.file_size_bytes,
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the virtual "Likes" playlist for a given user.
|
|
|
|
|
async fn likes_playlist_handler(
|
|
|
|
|
user_id: i64,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
|
|
|
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
r.cover_file_id as release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
r.year as release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__user_liked_track ult
|
|
|
|
|
JOIN furumusic__track t ON t.id = ult.track_id
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE ult.user_id = $1 AND t.is_hidden = false
|
|
|
|
|
ORDER BY ult.created_at DESC"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let track_items = build_track_items(tracks, pool).await?;
|
|
|
|
|
|
|
|
|
|
Json(PlaylistDetail {
|
|
|
|
|
id: -1,
|
|
|
|
|
title: "Likes".to_string(),
|
|
|
|
|
description: None,
|
|
|
|
|
is_own: true,
|
2026-05-26 00:19:11 +03:00
|
|
|
owner_name: None,
|
|
|
|
|
is_public: false,
|
|
|
|
|
is_saved: false,
|
2026-05-23 13:08:09 +03:00
|
|
|
kind: "likes".to_string(),
|
|
|
|
|
tracks: track_items,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/stream/{track_id} — Range-aware audio streaming
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn stream_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
request: &cot::http::Request<Body>,
|
|
|
|
|
path: Path<PathTrackId>,
|
|
|
|
|
) -> cot::Result<cot::http::Response<Body>> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let track_id = path.0.track_id;
|
|
|
|
|
|
|
|
|
|
// Look up track → audio_file_id → MediaFile
|
|
|
|
|
let media = sqlx::query_as::<_, MediaFileRow>(
|
|
|
|
|
r#"SELECT mf.file_path, mf.mime_type::text as mime_type, mf.file_size_bytes
|
|
|
|
|
FROM furumusic__track t
|
|
|
|
|
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
|
|
|
|
WHERE t.id = $1"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(track_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let Some(media) = media else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "track not found"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let full_path = std::path::Path::new(&config.agent_storage_dir).join(&media.file_path);
|
|
|
|
|
|
|
|
|
|
if !full_path.exists() {
|
|
|
|
|
return Ok(json_error(
|
|
|
|
|
StatusCode::NOT_FOUND,
|
|
|
|
|
"audio file not found on disk",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let file_size = media.file_size_bytes as u64;
|
|
|
|
|
|
|
|
|
|
// Parse Range header
|
|
|
|
|
let range_header = request.headers().get(RANGE).and_then(|v| v.to_str().ok());
|
|
|
|
|
|
|
|
|
|
if let Some(range_str) = range_header {
|
|
|
|
|
// Parse "bytes=START-END" or "bytes=START-"
|
|
|
|
|
if let Some(range) = parse_range(range_str, file_size) {
|
|
|
|
|
let (start, end) = range;
|
|
|
|
|
let chunk_size = end - start + 1;
|
|
|
|
|
|
|
|
|
|
let data = read_file_range(&full_path, start, chunk_size).await?;
|
|
|
|
|
|
|
|
|
|
let response = cot::http::Response::builder()
|
|
|
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
|
|
|
.header(CONTENT_TYPE, media.mime_type.as_str())
|
|
|
|
|
.header(ACCEPT_RANGES, "bytes")
|
2026-05-25 13:50:24 +03:00
|
|
|
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{file_size}"))
|
2026-05-23 13:08:09 +03:00
|
|
|
.header(CONTENT_LENGTH, chunk_size.to_string())
|
|
|
|
|
.body(Body::fixed(data))
|
|
|
|
|
.expect("valid response");
|
|
|
|
|
|
|
|
|
|
return Ok(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No Range or invalid range: return full file
|
|
|
|
|
let data = tokio::fs::read(&full_path)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let response = cot::http::Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(CONTENT_TYPE, media.mime_type.as_str())
|
|
|
|
|
.header(ACCEPT_RANGES, "bytes")
|
|
|
|
|
.header(CONTENT_LENGTH, file_size.to_string())
|
|
|
|
|
.body(Body::fixed(data))
|
|
|
|
|
.expect("valid response");
|
|
|
|
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
|
|
|
|
let bytes_prefix = "bytes=";
|
|
|
|
|
if !header.starts_with(bytes_prefix) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let range_spec = &header[bytes_prefix.len()..];
|
|
|
|
|
let parts: Vec<&str> = range_spec.splitn(2, '-').collect();
|
|
|
|
|
if parts.len() != 2 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let start: u64 = if parts[0].is_empty() {
|
|
|
|
|
// Suffix range: bytes=-N means last N bytes
|
|
|
|
|
let suffix: u64 = parts[1].parse().ok()?;
|
|
|
|
|
file_size.saturating_sub(suffix)
|
|
|
|
|
} else {
|
|
|
|
|
parts[0].parse().ok()?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let end: u64 = if parts[1].is_empty() || parts[0].is_empty() {
|
|
|
|
|
file_size - 1
|
|
|
|
|
} else {
|
|
|
|
|
parts[1].parse::<u64>().ok()?.min(file_size - 1)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if start > end || start >= file_size {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some((start, end))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 13:50:24 +03:00
|
|
|
async fn read_file_range(path: &std::path::Path, start: u64, length: u64) -> cot::Result<Vec<u8>> {
|
2026-05-23 13:08:09 +03:00
|
|
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
|
|
|
|
|
|
|
|
let mut file = tokio::fs::File::open(path)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
file.seek(std::io::SeekFrom::Start(start))
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let mut buf = vec![0u8; length as usize];
|
|
|
|
|
file.read_exact(&mut buf)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(buf)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/cover/{media_file_id}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn cover_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
config: &AppConfig,
|
|
|
|
|
path: Path<PathMediaFileId>,
|
|
|
|
|
) -> cot::Result<cot::http::Response<Body>> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let media_file_id = path.0.media_file_id;
|
|
|
|
|
|
|
|
|
|
let media = sqlx::query_as::<_, MediaFileRow>(
|
|
|
|
|
"SELECT file_path, mime_type::text as mime_type, file_size_bytes FROM furumusic__media_file WHERE id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(media_file_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let Some(media) = media else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "media file not found"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let full_path = std::path::Path::new(&config.agent_storage_dir).join(&media.file_path);
|
|
|
|
|
|
|
|
|
|
if !full_path.exists() {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "file not found on disk"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let data = tokio::fs::read(&full_path)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let response = cot::http::Response::builder()
|
|
|
|
|
.status(StatusCode::OK)
|
|
|
|
|
.header(CONTENT_TYPE, media.mime_type.as_str())
|
|
|
|
|
.header(CONTENT_LENGTH, data.len().to_string())
|
|
|
|
|
.header("Cache-Control", "public, max-age=86400")
|
|
|
|
|
.body(Body::fixed(data))
|
|
|
|
|
.expect("valid response");
|
|
|
|
|
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn get_state_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let state = sqlx::query_as::<_, PlaybackStateRow>(
|
|
|
|
|
r#"SELECT current_track_id, position_ms, queue_json, queue_position, shuffle, repeat_mode::text as repeat_mode, volume
|
|
|
|
|
FROM furumusic__playback_state WHERE user_id = $1"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let dto = match state {
|
|
|
|
|
Some(s) => {
|
2026-05-25 13:50:24 +03:00
|
|
|
let queue: Vec<i64> = serde_json::from_str(&s.queue_json).unwrap_or_default();
|
2026-05-23 13:08:09 +03:00
|
|
|
PlaybackStateDto {
|
|
|
|
|
current_track_id: s.current_track_id,
|
|
|
|
|
position_ms: s.position_ms,
|
|
|
|
|
queue,
|
|
|
|
|
queue_position: s.queue_position,
|
|
|
|
|
shuffle: s.shuffle,
|
|
|
|
|
repeat_mode: s.repeat_mode,
|
|
|
|
|
volume: s.volume,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None => PlaybackStateDto {
|
|
|
|
|
current_track_id: None,
|
|
|
|
|
position_ms: 0,
|
|
|
|
|
queue: Vec::new(),
|
|
|
|
|
queue_position: 0,
|
|
|
|
|
shuffle: false,
|
|
|
|
|
repeat_mode: "off".to_string(),
|
|
|
|
|
volume: 0.7,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Json(dto).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// PUT /api/player/state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn put_state_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
Json(dto): Json<PlaybackStateDto>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-25 13:50:24 +03:00
|
|
|
let queue_json =
|
|
|
|
|
serde_json::to_string(&dto.queue).map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"INSERT INTO furumusic__playback_state (user_id, current_track_id, position_ms, queue_json, queue_position, shuffle, repeat_mode, volume, updated_at)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
|
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
|
|
|
current_track_id = $2, position_ms = $3, queue_json = $4,
|
|
|
|
|
queue_position = $5, shuffle = $6, repeat_mode = $7, volume = $8, updated_at = $9"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(dto.current_track_id)
|
|
|
|
|
.bind(dto.position_ms)
|
|
|
|
|
.bind(&queue_json)
|
|
|
|
|
.bind(dto.queue_position)
|
|
|
|
|
.bind(dto.shuffle)
|
|
|
|
|
.bind(&dto.repeat_mode)
|
|
|
|
|
.bind(dto.volume)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/history
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-25 15:57:10 +03:00
|
|
|
async fn history_list_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<HistoryQuery>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let page = query.0.page.unwrap_or(1).max(1);
|
|
|
|
|
let per_page = query.0.limit.unwrap_or(20).clamp(1, 100);
|
|
|
|
|
let offset = (page - 1) as i64 * per_page as i64;
|
|
|
|
|
|
|
|
|
|
let total: i64 =
|
|
|
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query_as::<_, PlayHistoryRow>(
|
|
|
|
|
r#"SELECT ph.id,
|
|
|
|
|
ph.track_id,
|
|
|
|
|
t.title::text AS track_title,
|
|
|
|
|
r.title::text AS release_title,
|
|
|
|
|
ph.played_at::text AS played_at,
|
|
|
|
|
ph.duration_listened,
|
|
|
|
|
ph.completed
|
|
|
|
|
FROM furumusic__play_history ph
|
|
|
|
|
JOIN furumusic__track t ON t.id = ph.track_id
|
|
|
|
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
|
|
|
|
WHERE ph.user_id = $1
|
|
|
|
|
ORDER BY ph.played_at DESC, ph.id DESC
|
|
|
|
|
LIMIT $2 OFFSET $3"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(per_page as i64)
|
|
|
|
|
.bind(offset)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(PlayHistoryPage {
|
|
|
|
|
items: rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|row| PlayHistoryItem {
|
|
|
|
|
id: row.id,
|
|
|
|
|
track_id: row.track_id,
|
|
|
|
|
track_title: row.track_title,
|
|
|
|
|
release_title: row.release_title,
|
|
|
|
|
played_at: row.played_at,
|
|
|
|
|
duration_listened: row.duration_listened,
|
|
|
|
|
completed: row.completed,
|
|
|
|
|
})
|
|
|
|
|
.collect(),
|
|
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
per_page,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
async fn history_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
Json(entry): Json<HistoryEntry>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"INSERT INTO furumusic__play_history (user_id, track_id, played_at, duration_listened, completed)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(entry.track_id)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.bind(entry.duration_listened)
|
|
|
|
|
.bind(entry.completed)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/search?q=...&limit=N
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn search_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<SearchQuery>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let q = query.0.q.trim().to_lowercase();
|
|
|
|
|
if q.is_empty() {
|
|
|
|
|
return Json(SearchResults {
|
|
|
|
|
artists: Vec::new(),
|
|
|
|
|
releases: Vec::new(),
|
|
|
|
|
tracks: Vec::new(),
|
|
|
|
|
})
|
|
|
|
|
.into_response();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let limit = query.0.limit.unwrap_or(10).clamp(1, 50) as i64;
|
|
|
|
|
let short = q.chars().count() < 3;
|
|
|
|
|
|
|
|
|
|
let (artist_rows, release_rows, track_rows) = if short {
|
|
|
|
|
let a = sqlx::query_as::<_, SearchArtistRow>(
|
|
|
|
|
r#"SELECT a.id, a.name::text AS name, a.image_file_id,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
2026-05-25 14:30:33 +03:00
|
|
|
WHERE ra.artist_id = a.id), 0) AS release_count,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
|
|
|
|
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
|
|
|
|
WHERE ra.artist_id = a.id), 0) AS track_count
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__artist a
|
|
|
|
|
WHERE a.is_hidden = false AND a.name_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
ORDER BY a.name_sort LIMIT $2"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
|
|
|
|
r#"SELECT r.id, r.title::text AS title, r.release_type::text AS release_type,
|
|
|
|
|
r.year, r.cover_file_id,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count
|
|
|
|
|
FROM furumusic__release r
|
|
|
|
|
WHERE r.is_hidden = false AND r.title_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
ORDER BY r.title_sort LIMIT $2"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
|
|
|
|
r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
rel.cover_file_id AS release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
rel.year AS release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__track t
|
|
|
|
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
ORDER BY t.title_sort LIMIT $2"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
} else {
|
|
|
|
|
let a = sqlx::query_as::<_, SearchArtistRow>(
|
2026-05-25 14:30:33 +03:00
|
|
|
r#"SELECT id, name, image_file_id, release_count, track_count FROM (
|
2026-05-23 13:08:09 +03:00
|
|
|
SELECT a.id, a.name::text AS name, a.image_file_id,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
|
|
|
|
WHERE ra.artist_id = a.id), 0) AS release_count,
|
2026-05-25 14:30:33 +03:00
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
|
|
|
|
JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
|
|
|
|
WHERE ra.artist_id = a.id), 0) AS track_count,
|
2026-05-23 13:08:09 +03:00
|
|
|
MAX(sim) AS similarity
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT id, name, image_file_id, name_sort, similarity(name_sort, $1) AS sim
|
|
|
|
|
FROM furumusic__artist WHERE is_hidden = false AND name_sort % $1
|
|
|
|
|
UNION ALL
|
|
|
|
|
SELECT id, name, image_file_id, name_sort, 0.01::real AS sim
|
|
|
|
|
FROM furumusic__artist WHERE is_hidden = false AND name_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
) a
|
|
|
|
|
GROUP BY a.id, a.name, a.image_file_id
|
|
|
|
|
ORDER BY similarity DESC
|
|
|
|
|
LIMIT $2
|
|
|
|
|
) sub"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
let r = sqlx::query_as::<_, SearchReleaseRow>(
|
|
|
|
|
r#"SELECT id, title, release_type, year, cover_file_id, track_count FROM (
|
|
|
|
|
SELECT r.id, r.title::text AS title, r.release_type::text AS release_type,
|
|
|
|
|
r.year, r.cover_file_id,
|
|
|
|
|
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count,
|
|
|
|
|
MAX(sim) AS similarity
|
|
|
|
|
FROM (
|
|
|
|
|
SELECT id, title, release_type, year, cover_file_id, title_sort, similarity(title_sort, $1) AS sim
|
|
|
|
|
FROM furumusic__release WHERE is_hidden = false AND title_sort % $1
|
|
|
|
|
UNION ALL
|
|
|
|
|
SELECT id, title, release_type, year, cover_file_id, title_sort, 0.01::real AS sim
|
|
|
|
|
FROM furumusic__release WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
) r
|
|
|
|
|
GROUP BY r.id, r.title, r.release_type, r.year, r.cover_file_id
|
|
|
|
|
ORDER BY similarity DESC
|
|
|
|
|
LIMIT $2
|
|
|
|
|
) sub"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
let t = sqlx::query_as::<_, SearchTrackRow>(
|
2026-05-25 23:04:58 +03:00
|
|
|
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate,
|
2026-05-25 23:04:58 +03:00
|
|
|
audio_sample_rate, audio_bit_depth, file_size_bytes FROM (
|
2026-05-23 13:08:09 +03:00
|
|
|
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
|
|
|
|
rel.cover_file_id AS release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
rel.year AS release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes,
|
2026-05-23 13:08:09 +03:00
|
|
|
MAX(sim) AS similarity
|
|
|
|
|
FROM (
|
2026-05-25 23:04:58 +03:00
|
|
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
2026-05-23 13:08:09 +03:00
|
|
|
similarity(title_sort, $1) AS sim
|
|
|
|
|
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
|
|
|
|
|
UNION ALL
|
2026-05-25 23:04:58 +03:00
|
|
|
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
|
2026-05-23 13:08:09 +03:00
|
|
|
0.01::real AS sim
|
|
|
|
|
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
|
|
|
|
|
) t
|
|
|
|
|
JOIN furumusic__release rel ON rel.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-26 11:15:27 +03:00
|
|
|
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year,
|
2026-05-25 23:04:58 +03:00
|
|
|
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
ORDER BY similarity DESC
|
|
|
|
|
LIMIT $2
|
|
|
|
|
) sub"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&q)
|
|
|
|
|
.bind(limit)
|
|
|
|
|
.fetch_all(pool);
|
|
|
|
|
|
|
|
|
|
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Collect track IDs for batch artist lookup
|
|
|
|
|
let track_ids: Vec<i64> = track_rows.iter().map(|t| t.id).collect();
|
|
|
|
|
|
|
|
|
|
let track_artists = if track_ids.is_empty() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
sqlx::query_as::<_, TrackArtistRow>(
|
|
|
|
|
r#"SELECT ta.track_id, ta.artist_id, a.name::text AS artist_name, ta.role::text AS role
|
|
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
|
|
|
|
WHERE ta.track_id = ANY($1)
|
|
|
|
|
ORDER BY ta.track_id, ta.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&track_ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut track_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
let mut track_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for ta in &track_artists {
|
|
|
|
|
let artist_ref = ArtistRef {
|
|
|
|
|
id: ta.artist_id,
|
|
|
|
|
name: ta.artist_name.clone(),
|
|
|
|
|
};
|
|
|
|
|
if ta.role == "featuring" {
|
|
|
|
|
track_feat_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
} else {
|
|
|
|
|
track_main_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let artists: Vec<ArtistCard> = artist_rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| ArtistCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
image_url: cover_url(r.image_file_id),
|
|
|
|
|
release_count: r.release_count,
|
2026-05-25 14:30:33 +03:00
|
|
|
track_count: r.track_count,
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-05-25 23:04:58 +03:00
|
|
|
let release_ids: Vec<i64> = release_rows.iter().map(|r| r.id).collect();
|
|
|
|
|
let mut release_uploaders = load_release_uploaders(pool, &release_ids)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
let releases: Vec<ReleaseCard> = release_rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| ReleaseCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
title: r.title,
|
|
|
|
|
release_type: r.release_type,
|
|
|
|
|
year: r.year,
|
|
|
|
|
cover_url: cover_url(r.cover_file_id),
|
|
|
|
|
track_count: r.track_count,
|
2026-05-25 23:04:58 +03:00
|
|
|
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
|
2026-05-23 13:08:09 +03:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let tracks: Vec<TrackItem> = track_rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|t| {
|
|
|
|
|
let tid = t.id;
|
|
|
|
|
TrackItem {
|
|
|
|
|
id: t.id,
|
|
|
|
|
title: t.title,
|
|
|
|
|
track_number: t.track_number,
|
|
|
|
|
disc_number: t.disc_number,
|
|
|
|
|
duration_seconds: t.duration_seconds,
|
|
|
|
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
2026-05-26 11:15:27 +03:00
|
|
|
release_year: t.release_year,
|
2026-05-23 13:08:09 +03:00
|
|
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
|
|
|
|
stream_url: format!("/api/player/stream/{tid}"),
|
2026-05-25 23:04:58 +03:00
|
|
|
uploader_name: t.uploader_name,
|
|
|
|
|
audio_format: t.audio_format,
|
|
|
|
|
audio_bitrate: t.audio_bitrate,
|
|
|
|
|
audio_sample_rate: t.audio_sample_rate,
|
|
|
|
|
audio_bit_depth: t.audio_bit_depth,
|
|
|
|
|
file_size_bytes: t.file_size_bytes,
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Json(SearchResults {
|
|
|
|
|
artists,
|
|
|
|
|
releases,
|
|
|
|
|
tracks,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/playlists — create playlist
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn create_playlist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
Json(body): Json<CreatePlaylistRequest>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let title = body.title.trim().to_string();
|
|
|
|
|
if title.is_empty() {
|
|
|
|
|
return Ok(json_error(StatusCode::BAD_REQUEST, "title is required"));
|
|
|
|
|
}
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
let row: (i64,) = sqlx::query_as(
|
|
|
|
|
"INSERT INTO furumusic__playlist (owner_id, title, is_public, created_at, updated_at) \
|
|
|
|
|
VALUES ($1, $2, false, $3, $3) RETURNING id",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(&title)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(PlaylistCard {
|
|
|
|
|
id: row.0,
|
|
|
|
|
title,
|
|
|
|
|
track_count: 0,
|
|
|
|
|
is_own: true,
|
2026-05-26 00:19:11 +03:00
|
|
|
owner_name: Some(user.name),
|
|
|
|
|
is_public: false,
|
|
|
|
|
is_saved: false,
|
2026-05-23 13:08:09 +03:00
|
|
|
kind: "user".to_string(),
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// PUT /api/player/playlists/{id} — rename / update playlist
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn update_playlist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
Json(body): Json<UpdatePlaylistRequest>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let playlist_id = path.0.id;
|
|
|
|
|
// Verify ownership
|
2026-05-25 13:50:24 +03:00
|
|
|
let owner: Option<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
let Some(owner) = owner else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
|
|
|
|
};
|
|
|
|
|
if owner.0 != user.id {
|
|
|
|
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
|
|
|
|
}
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
if let Some(title) = &body.title {
|
|
|
|
|
let t = title.trim();
|
|
|
|
|
if !t.is_empty() {
|
|
|
|
|
sqlx::query("UPDATE furumusic__playlist SET title = $1, updated_at = $2 WHERE id = $3")
|
|
|
|
|
.bind(t)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(desc) = &body.description {
|
2026-05-25 13:50:24 +03:00
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE furumusic__playlist SET description = $1, updated_at = $2 WHERE id = $3",
|
|
|
|
|
)
|
|
|
|
|
.bind(desc)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// DELETE /api/player/playlists/{id}
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn delete_playlist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let playlist_id = path.0.id;
|
2026-05-25 13:50:24 +03:00
|
|
|
let owner: Option<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
let Some(owner) = owner else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
|
|
|
|
};
|
|
|
|
|
if owner.0 != user.id {
|
|
|
|
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
|
|
|
|
}
|
|
|
|
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
sqlx::query("DELETE FROM furumusic__saved_playlist WHERE playlist_id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
sqlx::query("DELETE FROM furumusic__playlist WHERE id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/playlists/{id}/tracks — add tracks to playlist
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn add_tracks_to_playlist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
Json(body): Json<AddTracksRequest>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let playlist_id = path.0.id;
|
2026-05-25 13:50:24 +03:00
|
|
|
let owner: Option<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
let Some(owner) = owner else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
|
|
|
|
};
|
|
|
|
|
if owner.0 != user.id {
|
|
|
|
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get next position
|
|
|
|
|
let max_pos: (Option<i32>,) = sqlx::query_as(
|
|
|
|
|
"SELECT MAX(position) FROM furumusic__playlist_track WHERE playlist_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let mut pos = max_pos.0.unwrap_or(-1) + 1;
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
|
|
|
|
|
for track_id in &body.track_ids {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO furumusic__playlist_track (playlist_id, track_id, position, added_at, added_by_user_id) \
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
|
|
|
)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.bind(track_id)
|
|
|
|
|
.bind(pos)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
pos += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn remove_track_from_playlist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
Json(body): Json<RemoveTrackRequest>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let playlist_id = path.0.id;
|
2026-05-25 13:50:24 +03:00
|
|
|
let owner: Option<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
let Some(owner) = owner else {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
|
|
|
|
};
|
|
|
|
|
if owner.0 != user.id {
|
|
|
|
|
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 13:50:24 +03:00
|
|
|
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.bind(body.track_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
// Re-number positions
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"WITH ordered AS (
|
|
|
|
|
SELECT id, ROW_NUMBER() OVER (ORDER BY position) - 1 as new_pos
|
|
|
|
|
FROM furumusic__playlist_track WHERE playlist_id = $1
|
|
|
|
|
)
|
|
|
|
|
UPDATE furumusic__playlist_track pt
|
|
|
|
|
SET position = o.new_pos
|
|
|
|
|
FROM ordered o WHERE pt.id = o.id"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(playlist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Json(serde_json::json!({"ok": true})).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/likes/toggle/{track_id} — toggle like on a track
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn toggle_like_track_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathTrackId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let track_id = path.0.track_id;
|
|
|
|
|
let existing: Option<(i64,)> = sqlx::query_as(
|
|
|
|
|
"SELECT id FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(track_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if existing.is_some() {
|
2026-05-25 13:50:24 +03:00
|
|
|
sqlx::query("DELETE FROM furumusic__user_liked_track WHERE user_id = $1 AND track_id = $2")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(track_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
Json(LikeStatus { liked: false }).into_response()
|
|
|
|
|
} else {
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO furumusic__user_liked_track (user_id, track_id, created_at) VALUES ($1, $2, $3)",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(track_id)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(LikeStatus { liked: true }).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/likes/release/{release_id} — like all tracks in release
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn like_release_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let release_id = path.0.id;
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
|
|
|
|
|
// Check if ALL tracks in this release are already liked
|
|
|
|
|
let total: (i64,) = sqlx::query_as(
|
|
|
|
|
"SELECT COUNT(*) FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
|
|
|
|
|
)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let liked_count: (i64,) = sqlx::query_as(
|
|
|
|
|
r#"SELECT COUNT(*) FROM furumusic__user_liked_track ult
|
|
|
|
|
JOIN furumusic__track t ON t.id = ult.track_id
|
|
|
|
|
WHERE ult.user_id = $1 AND t.release_id = $2 AND t.is_hidden = false"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if liked_count.0 >= total.0 && total.0 > 0 {
|
|
|
|
|
// Unlike all tracks in release
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"DELETE FROM furumusic__user_liked_track
|
|
|
|
|
WHERE user_id = $1 AND track_id IN (
|
|
|
|
|
SELECT id FROM furumusic__track WHERE release_id = $2 AND is_hidden = false
|
|
|
|
|
)"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(LikeStatus { liked: false }).into_response()
|
|
|
|
|
} else {
|
|
|
|
|
// Like all tracks in release (skip already liked)
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"INSERT INTO furumusic__user_liked_track (user_id, track_id, created_at)
|
|
|
|
|
SELECT $1, t.id, $3
|
|
|
|
|
FROM furumusic__track t
|
|
|
|
|
WHERE t.release_id = $2 AND t.is_hidden = false
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM furumusic__user_liked_track ult
|
|
|
|
|
WHERE ult.user_id = $1 AND ult.track_id = t.id
|
|
|
|
|
)"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(LikeStatus { liked: true }).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/likes — get all liked track IDs for current user
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn liked_ids_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
2026-05-25 13:50:24 +03:00
|
|
|
let rows: Vec<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT track_id FROM furumusic__user_liked_track WHERE user_id = $1")
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
Json(LikedIds {
|
|
|
|
|
track_ids: rows.into_iter().map(|r| r.0).collect(),
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 17:41:00 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// GET /api/player/follows — get followed artists for current user
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn followed_artists_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rows = sqlx::query_as::<_, ArtistRow>(
|
|
|
|
|
r#"SELECT a.id, a.name::text as name, a.image_file_id,
|
|
|
|
|
COALESCE(s.release_count, 0)::bigint AS release_count,
|
|
|
|
|
COALESCE(s.track_count, 0)::bigint AS track_count
|
|
|
|
|
FROM furumusic__user_followed_artist ufa
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ufa.artist_id
|
|
|
|
|
LEFT JOIN (
|
|
|
|
|
SELECT ra.artist_id,
|
|
|
|
|
COUNT(DISTINCT r.id) AS release_count,
|
|
|
|
|
COUNT(t.id) AS track_count
|
|
|
|
|
FROM furumusic__release_artist ra
|
|
|
|
|
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
|
|
|
|
|
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
|
|
|
|
|
WHERE ra.position = 0
|
|
|
|
|
GROUP BY ra.artist_id
|
|
|
|
|
) s ON s.artist_id = a.id
|
|
|
|
|
WHERE ufa.user_id = $1 AND a.is_hidden = false
|
|
|
|
|
ORDER BY ufa.created_at DESC, a.name_sort"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let artist_ids = rows.iter().map(|row| row.id).collect();
|
|
|
|
|
let artists = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|r| ArtistCard {
|
|
|
|
|
id: r.id,
|
|
|
|
|
name: r.name,
|
|
|
|
|
image_url: cover_url(r.image_file_id),
|
|
|
|
|
release_count: r.release_count,
|
|
|
|
|
track_count: r.track_count,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Json(FollowedArtists {
|
|
|
|
|
artist_ids,
|
|
|
|
|
artists,
|
|
|
|
|
})
|
|
|
|
|
.into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/follows/toggle/{id} — follow/unfollow artist
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn toggle_follow_artist_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
let artist_id = path.0.id;
|
|
|
|
|
|
|
|
|
|
let artist_exists: Option<(i64,)> =
|
|
|
|
|
sqlx::query_as("SELECT id FROM furumusic__artist WHERE id = $1 AND is_hidden = false")
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if artist_exists.is_none() {
|
|
|
|
|
return Ok(json_error(StatusCode::NOT_FOUND, "artist not found"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let existing: Option<(i64,)> = sqlx::query_as(
|
|
|
|
|
"SELECT id FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if existing.is_some() {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"DELETE FROM furumusic__user_followed_artist WHERE user_id = $1 AND artist_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(FollowStatus { followed: false }).into_response()
|
|
|
|
|
} else {
|
|
|
|
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
r#"INSERT INTO furumusic__user_followed_artist (user_id, artist_id, created_at)
|
|
|
|
|
VALUES ($1, $2, $3)
|
|
|
|
|
ON CONFLICT (user_id, artist_id) DO NOTHING"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(user.id)
|
|
|
|
|
.bind(artist_id)
|
|
|
|
|
.bind(&now)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
Json(FollowStatus { followed: true }).into_response()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 13:08:09 +03:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// POST /api/player/tracks-by-ids
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async fn tracks_by_ids_handler(
|
|
|
|
|
session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
pool: &sqlx::PgPool,
|
|
|
|
|
Json(body): Json<TracksByIdsRequest>,
|
|
|
|
|
) -> cot::Result<cot::response::Response> {
|
|
|
|
|
let Some(_user) = auth::get_session_user(&session, &db).await else {
|
|
|
|
|
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if body.ids.is_empty() {
|
|
|
|
|
return Json(Vec::<TrackItem>::new()).into_response();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Limit to 500 IDs to prevent abuse
|
|
|
|
|
let ids: Vec<i64> = body.ids.into_iter().take(500).collect();
|
|
|
|
|
|
|
|
|
|
let tracks = sqlx::query_as::<_, TrackRow>(
|
|
|
|
|
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
|
|
|
|
t.duration_seconds, t.cover_file_id,
|
2026-05-25 23:04:58 +03:00
|
|
|
r.cover_file_id as release_cover_file_id,
|
2026-05-26 11:15:27 +03:00
|
|
|
r.year as release_year,
|
2026-05-25 23:04:58 +03:00
|
|
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
|
|
|
|
mf.audio_format,
|
|
|
|
|
mf.audio_bitrate,
|
|
|
|
|
mf.audio_sample_rate,
|
|
|
|
|
mf.audio_bit_depth,
|
|
|
|
|
mf.file_size_bytes
|
2026-05-23 13:08:09 +03:00
|
|
|
FROM furumusic__track t
|
|
|
|
|
JOIN furumusic__release r ON r.id = t.release_id
|
2026-05-25 23:04:58 +03:00
|
|
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
2026-05-23 13:08:09 +03:00
|
|
|
WHERE t.id = ANY($1) AND t.is_hidden = false"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let track_ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
|
|
|
|
|
|
|
|
|
|
let track_artists = if track_ids.is_empty() {
|
|
|
|
|
Vec::new()
|
|
|
|
|
} else {
|
|
|
|
|
sqlx::query_as::<_, TrackArtistRow>(
|
|
|
|
|
r#"SELECT ta.track_id, ta.artist_id, a.name::text as artist_name, ta.role::text as role
|
|
|
|
|
FROM furumusic__track_artist ta
|
|
|
|
|
JOIN furumusic__artist a ON a.id = ta.artist_id
|
|
|
|
|
WHERE ta.track_id = ANY($1)
|
|
|
|
|
ORDER BY ta.track_id, ta.position"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(&track_ids)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| cot::Error::internal(e.to_string()))?
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut track_main_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
let mut track_feat_artists: std::collections::HashMap<i64, Vec<ArtistRef>> =
|
|
|
|
|
std::collections::HashMap::new();
|
|
|
|
|
|
|
|
|
|
for ta in &track_artists {
|
|
|
|
|
let artist_ref = ArtistRef {
|
|
|
|
|
id: ta.artist_id,
|
|
|
|
|
name: ta.artist_name.clone(),
|
|
|
|
|
};
|
|
|
|
|
if ta.role == "featuring" {
|
|
|
|
|
track_feat_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
} else {
|
|
|
|
|
track_main_artists
|
|
|
|
|
.entry(ta.track_id)
|
|
|
|
|
.or_default()
|
|
|
|
|
.push(artist_ref);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build a map from id -> TrackItem
|
|
|
|
|
let mut track_map: std::collections::HashMap<i64, TrackItem> = std::collections::HashMap::new();
|
|
|
|
|
for t in tracks {
|
|
|
|
|
let tid = t.id;
|
2026-05-25 13:50:24 +03:00
|
|
|
track_map.insert(
|
|
|
|
|
tid,
|
|
|
|
|
TrackItem {
|
|
|
|
|
id: t.id,
|
|
|
|
|
title: t.title,
|
|
|
|
|
track_number: t.track_number,
|
|
|
|
|
disc_number: t.disc_number,
|
|
|
|
|
duration_seconds: t.duration_seconds,
|
|
|
|
|
artists: track_main_artists.remove(&tid).unwrap_or_default(),
|
|
|
|
|
featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(),
|
2026-05-26 11:15:27 +03:00
|
|
|
release_year: t.release_year,
|
2026-05-25 13:50:24 +03:00
|
|
|
cover_url: track_cover_url(t.cover_file_id, t.release_cover_file_id),
|
|
|
|
|
stream_url: format!("/api/player/stream/{tid}"),
|
2026-05-25 23:04:58 +03:00
|
|
|
uploader_name: t.uploader_name,
|
|
|
|
|
audio_format: t.audio_format,
|
|
|
|
|
audio_bitrate: t.audio_bitrate,
|
|
|
|
|
audio_sample_rate: t.audio_sample_rate,
|
|
|
|
|
audio_bit_depth: t.audio_bit_depth,
|
|
|
|
|
file_size_bytes: t.file_size_bytes,
|
2026-05-25 13:50:24 +03:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reorder results to match input order
|
2026-05-25 13:50:24 +03:00
|
|
|
let result: Vec<TrackItem> = ids.iter().filter_map(|id| track_map.remove(id)).collect();
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
Json(result).into_response()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// PlayerApp
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
pub struct PlayerApp {
|
|
|
|
|
config: Arc<AppConfig>,
|
2026-05-25 15:40:07 +03:00
|
|
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PlayerApp {
|
2026-05-25 15:40:07 +03:00
|
|
|
pub fn new(
|
|
|
|
|
config: Arc<AppConfig>,
|
|
|
|
|
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
|
|
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
config,
|
|
|
|
|
scheduler_handle,
|
|
|
|
|
}
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl App for PlayerApp {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"player"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn router(&self) -> Router {
|
|
|
|
|
let pool_config = Arc::clone(&self.config);
|
2026-05-25 13:50:24 +03:00
|
|
|
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service: Arc<tokio::sync::OnceCell<Arc<TorrentService>>> =
|
|
|
|
|
Arc::new(tokio::sync::OnceCell::new());
|
2026-05-23 13:08:09 +03:00
|
|
|
|
|
|
|
|
Router::with_urls([
|
2026-05-25 14:42:25 +03:00
|
|
|
// -- Current user profile --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/me",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
get(move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
me_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_me",
|
|
|
|
|
),
|
2026-05-26 14:47:10 +03:00
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/agent-queue",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
get(move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
agent_queue_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_agent_queue",
|
|
|
|
|
),
|
2026-05-25 15:40:07 +03:00
|
|
|
// -- Torrent import widget --
|
2026-05-26 12:55:11 +03:00
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/torrents",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
|
|
|
|
get(move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
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 pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
match service.list(pg_pool, user.id).await {
|
|
|
|
|
Ok(items) => Json(items).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_torrent_list",
|
|
|
|
|
),
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/torrents/session/{id}",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
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 pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(
|
|
|
|
|
&scheduler_handle,
|
|
|
|
|
)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
match service.details(pg_pool, user.id, &path.0.id).await {
|
|
|
|
|
Ok(details) => Json(details).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.delete(move |session: Session, db: Database, path: Path<PathStringId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
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 pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
match service.remove(pg_pool, user.id, &path.0.id).await {
|
|
|
|
|
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_torrent_detail",
|
|
|
|
|
),
|
2026-05-25 15:40:07 +03:00
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/torrents/preview",
|
|
|
|
|
{
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
|
|
|
|
post(
|
|
|
|
|
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
|
|
|
|
async move {
|
2026-05-26 12:55:11 +03:00
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
2026-05-25 15:40:07 +03:00
|
|
|
return Ok(json_error(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"not authenticated",
|
|
|
|
|
));
|
|
|
|
|
};
|
2026-05-26 12:55:11 +03:00
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-25 15:40:07 +03:00
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-26 12:55:11 +03:00
|
|
|
match service.preview(pg_pool, user.id, json.0).await {
|
2026-05-25 15:40:07 +03:00
|
|
|
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",
|
|
|
|
|
{
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
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>| {
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
|
|
|
|
async move {
|
2026-05-25 23:04:58 +03:00
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
2026-05-25 15:40:07 +03:00
|
|
|
return Ok(json_error(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"not authenticated",
|
|
|
|
|
));
|
|
|
|
|
};
|
2026-05-26 12:55:11 +03:00
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-25 15:40:07 +03:00
|
|
|
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(
|
2026-05-26 12:55:11 +03:00
|
|
|
pg_pool,
|
2026-05-25 15:40:07 +03:00
|
|
|
&path.0.id,
|
|
|
|
|
json.0.selected_files,
|
|
|
|
|
live_config.agent_inbox_dir,
|
2026-05-25 23:04:58 +03:00
|
|
|
user.id,
|
2026-05-25 15:40:07 +03:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(job) => Json(job).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
"player_torrent_start",
|
|
|
|
|
),
|
2026-05-26 14:47:10 +03:00
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/torrents/{id}/pause",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
|
|
|
|
post(move |session: Session, db: Database, path: Path<PathStringId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
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 pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
match service.pause(pg_pool, user.id, &path.0.id).await {
|
|
|
|
|
Ok(job) => Json(job).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_torrent_pause",
|
|
|
|
|
),
|
2026-05-25 15:40:07 +03:00
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/torrents/{id}/status",
|
|
|
|
|
{
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&self.scheduler_handle);
|
|
|
|
|
get(
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathStringId>| {
|
2026-05-26 12:55:11 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 15:40:07 +03:00
|
|
|
let torrent_service = Arc::clone(&torrent_service);
|
|
|
|
|
let scheduler_handle = Arc::clone(&scheduler_handle);
|
|
|
|
|
async move {
|
2026-05-26 12:55:11 +03:00
|
|
|
let Some(user) = auth::get_session_user(&session, &db).await else {
|
2026-05-25 15:40:07 +03:00
|
|
|
return Ok(json_error(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
"not authenticated",
|
|
|
|
|
));
|
|
|
|
|
};
|
2026-05-26 12:55:11 +03:00
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-25 15:40:07 +03:00
|
|
|
let service = torrent_service
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-26 12:55:11 +03:00
|
|
|
match service.status(pg_pool, user.id, &path.0.id).await {
|
2026-05-25 15:40:07 +03:00
|
|
|
Ok(job) => Json(job).into_response(),
|
|
|
|
|
Err(err) => {
|
|
|
|
|
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
"player_torrent_status",
|
|
|
|
|
),
|
2026-05-23 13:08:09 +03:00
|
|
|
// -- Artists (paginated) --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/artists",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
get(move |session: Session, db: Database,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<PaginationQuery>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
artists_handler(session, db, pg_pool, query).await
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_artists",
|
|
|
|
|
),
|
|
|
|
|
// -- Artist detail --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/artists/{id}",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
get(move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
artist_detail_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_artist_detail",
|
|
|
|
|
),
|
|
|
|
|
// -- Release detail --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/releases/{id}",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
get(move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
release_detail_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
"player_release_detail",
|
|
|
|
|
),
|
|
|
|
|
// -- Playlists (list + create) --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/playlists",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
playlists_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, json: Json<CreatePlaylistRequest>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
create_playlist_handler(session, db, pg_pool, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_playlists",
|
|
|
|
|
),
|
|
|
|
|
// -- Playlist detail (get, update, delete) --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/playlists/{id}",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
playlist_detail_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.put({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 13:50:24 +03:00
|
|
|
move |session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
json: Json<UpdatePlaylistRequest>| {
|
2026-05-23 13:08:09 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
update_playlist_handler(session, db, pg_pool, path, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.delete({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
delete_playlist_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_playlist_detail",
|
|
|
|
|
),
|
|
|
|
|
// -- Playlist tracks (add / remove) --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/playlists/{id}/tracks",
|
|
|
|
|
post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 13:50:24 +03:00
|
|
|
move |session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
json: Json<AddTracksRequest>| {
|
2026-05-23 13:08:09 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
add_tracks_to_playlist_handler(session, db, pg_pool, path, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.delete({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
2026-05-25 13:50:24 +03:00
|
|
|
move |session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
path: Path<PathId>,
|
|
|
|
|
json: Json<RemoveTrackRequest>| {
|
2026-05-23 13:08:09 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
2026-05-25 13:50:24 +03:00
|
|
|
remove_track_from_playlist_handler(session, db, pg_pool, path, json)
|
|
|
|
|
.await
|
2026-05-23 13:08:09 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_playlist_tracks",
|
|
|
|
|
),
|
|
|
|
|
// -- Likes (get liked IDs) --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/likes",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
liked_ids_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_likes",
|
|
|
|
|
),
|
|
|
|
|
// -- Toggle like on track --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/likes/toggle/{track_id}",
|
|
|
|
|
post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathTrackId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
toggle_like_track_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_like_toggle",
|
|
|
|
|
),
|
|
|
|
|
// -- Like/unlike release --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/likes/release/{id}",
|
|
|
|
|
post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
like_release_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_like_release",
|
|
|
|
|
),
|
2026-05-25 17:41:00 +03:00
|
|
|
// -- Followed artists --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/follows",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
followed_artists_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_follows",
|
|
|
|
|
),
|
|
|
|
|
// -- Follow/unfollow artist --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/follows/toggle/{id}",
|
|
|
|
|
post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
toggle_follow_artist_handler(session, db, pg_pool, path).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_follow_toggle",
|
|
|
|
|
),
|
2026-05-23 13:08:09 +03:00
|
|
|
// -- Audio stream --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/stream/{track_id}",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let config = Arc::clone(&self.config);
|
2026-05-25 13:50:24 +03:00
|
|
|
get(
|
|
|
|
|
move |session: Session,
|
|
|
|
|
db: Database,
|
2026-05-23 13:08:09 +03:00
|
|
|
path: Path<PathTrackId>,
|
|
|
|
|
request: cot::request::Request| {
|
2026-05-25 13:50:24 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let config = Arc::clone(&config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
stream_handler(session, db, pg_pool, &config, &request, path).await
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-23 13:08:09 +03:00
|
|
|
},
|
|
|
|
|
"player_stream",
|
|
|
|
|
),
|
|
|
|
|
// -- Cover art --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/cover/{media_file_id}",
|
|
|
|
|
{
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let config = Arc::clone(&self.config);
|
2026-05-25 13:50:24 +03:00
|
|
|
get(
|
|
|
|
|
move |session: Session, db: Database, path: Path<PathMediaFileId>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
let config = Arc::clone(&config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
cover_handler(session, db, pg_pool, &config, path).await
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-23 13:08:09 +03:00
|
|
|
},
|
|
|
|
|
"player_cover",
|
|
|
|
|
),
|
|
|
|
|
// -- Playback state GET --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/state",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
get_state_handler(session, db, pg_pool).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.put({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, json: Json<PlaybackStateDto>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
put_state_handler(session, db, pg_pool, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.post({
|
|
|
|
|
// POST handler for sendBeacon (used on page unload)
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, json: Json<PlaybackStateDto>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
put_state_handler(session, db, pg_pool, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_state",
|
|
|
|
|
),
|
|
|
|
|
// -- Play history --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/history",
|
2026-05-25 15:57:10 +03:00
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session,
|
|
|
|
|
db: Database,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<HistoryQuery>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
history_list_handler(session, db, pg_pool, query).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.post({
|
2026-05-23 13:08:09 +03:00
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, json: Json<HistoryEntry>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
history_handler(session, db, pg_pool, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_history",
|
|
|
|
|
),
|
|
|
|
|
// -- Search --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/search",
|
|
|
|
|
get({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database,
|
|
|
|
|
query: cot::request::extractors::UrlQuery<SearchQuery>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
search_handler(session, db, pg_pool, query).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_search",
|
|
|
|
|
),
|
|
|
|
|
// -- Tracks by IDs --
|
|
|
|
|
Route::with_handler_and_name(
|
|
|
|
|
"/tracks-by-ids",
|
|
|
|
|
post({
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
move |session: Session, db: Database, json: Json<TracksByIdsRequest>| {
|
|
|
|
|
let pool = Arc::clone(&pool);
|
|
|
|
|
let pool_config = Arc::clone(&pool_config);
|
|
|
|
|
async move {
|
|
|
|
|
let pg_pool = pool
|
|
|
|
|
.get_or_init(|| async {
|
|
|
|
|
sqlx::postgres::PgPoolOptions::new()
|
|
|
|
|
.max_connections(5)
|
|
|
|
|
.connect(&pool_config.database_url)
|
|
|
|
|
.await
|
|
|
|
|
.expect("player pool")
|
|
|
|
|
})
|
|
|
|
|
.await;
|
|
|
|
|
tracks_by_ids_handler(session, db, pg_pool, json).await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
"player_tracks_by_ids",
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|