Added user attribution

This commit is contained in:
2026-05-25 23:04:58 +03:00
parent 8530016d35
commit 5f925be29b
13 changed files with 901 additions and 443 deletions
+122 -437
View File
@@ -10,8 +10,6 @@ use cot::router::method::{get, post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body, Template};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::auth;
use crate::config::AppConfig;
@@ -19,6 +17,16 @@ use crate::i18n::Translations;
use crate::scheduler::SchedulerHandle;
use crate::torrents::{TorrentPreviewRequest, TorrentService, TorrentStartRequest};
mod dto;
mod helpers;
mod queries;
mod rows;
use dto::*;
use helpers::{cover_url, load_release_uploaders, track_cover_url};
use queries::*;
use rows::*;
// ---------------------------------------------------------------------------
// JSON error helper
// ---------------------------------------------------------------------------
@@ -32,429 +40,6 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
.expect("valid response")
}
// ---------------------------------------------------------------------------
// DTO structs
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistCard {
id: i64,
name: String,
image_url: Option<String>,
release_count: i64,
track_count: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct Paginated<T: Serialize> {
items: Vec<T>,
total: i64,
page: i32,
per_page: i32,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseCard {
id: i64,
title: String,
release_type: String,
year: Option<i32>,
cover_url: Option<String>,
track_count: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistDetail {
id: i64,
name: String,
image_url: Option<String>,
total_track_count: i64,
total_play_count: i64,
releases: Vec<ReleaseCard>,
featured_tracks: Vec<ArtistAppearanceTrack>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistRef {
id: i64,
name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct TrackItem {
id: i64,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
artists: Vec<ArtistRef>,
featured_artists: Vec<ArtistRef>,
cover_url: Option<String>,
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ArtistAppearanceTrack {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
artists: Vec<ArtistRef>,
featured_artists: Vec<ArtistRef>,
cover_url: Option<String>,
stream_url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ReleaseDetail {
id: i64,
title: String,
release_type: String,
year: Option<i32>,
cover_url: Option<String>,
artists: Vec<ArtistRef>,
tracks: Vec<TrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlaylistCard {
id: i64,
title: String,
track_count: i64,
is_own: bool,
kind: String, // "user" or "likes"
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct PlaybackStateDto {
current_track_id: Option<i64>,
position_ms: i32,
queue: Vec<i64>,
queue_position: i32,
shuffle: bool,
repeat_mode: String,
volume: f64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlaylistDetail {
id: i64,
title: String,
description: Option<String>,
is_own: bool,
kind: String,
tracks: Vec<TrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SearchResults {
artists: Vec<ArtistCard>,
releases: Vec<ReleaseCard>,
tracks: Vec<TrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct UserStats {
liked_tracks: i64,
playlists: i64,
plays: i64,
listened_minutes: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct UserProfile {
name: String,
role: String,
stats: UserStats,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlayHistoryItem {
id: i64,
track_id: i64,
track_title: String,
release_title: Option<String>,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct PlayHistoryPage {
items: Vec<PlayHistoryItem>,
total: i64,
page: i32,
per_page: i32,
}
#[derive(Debug, Deserialize)]
struct HistoryEntry {
track_id: i64,
duration_listened: Option<i32>,
completed: bool,
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
page: Option<i32>,
limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct TracksByIdsRequest {
ids: Vec<i64>,
}
#[derive(Debug, Deserialize)]
struct CreatePlaylistRequest {
title: String,
}
#[derive(Debug, Deserialize)]
struct UpdatePlaylistRequest {
title: Option<String>,
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddTracksRequest {
track_ids: Vec<i64>,
}
#[derive(Debug, Deserialize)]
struct RemoveTrackRequest {
track_id: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LikeStatus {
liked: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LikedIds {
track_ids: Vec<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct FollowStatus {
followed: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
struct FollowedArtists {
artist_ids: Vec<i64>,
artists: Vec<ArtistCard>,
}
// ---------------------------------------------------------------------------
// Query helpers
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct PaginationQuery {
page: Option<i32>,
limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct PathId {
id: i64,
}
#[derive(Debug, Deserialize)]
struct PathStringId {
id: String,
}
#[derive(Debug, Deserialize)]
struct SearchQuery {
q: String,
limit: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct PathTrackId {
track_id: i64,
}
#[derive(Debug, Deserialize)]
struct PathMediaFileId {
media_file_id: i64,
}
// ---------------------------------------------------------------------------
// sqlx row types
// ---------------------------------------------------------------------------
#[derive(sqlx::FromRow)]
struct ArtistRow {
id: i64,
name: String,
image_file_id: Option<i64>,
release_count: i64,
track_count: i64,
}
#[derive(sqlx::FromRow)]
struct CountRow {
count: i64,
}
#[derive(sqlx::FromRow)]
struct ReleaseRow {
id: i64,
title: String,
release_type: String,
year: Option<i32>,
cover_file_id: Option<i64>,
track_count: i64,
}
#[derive(sqlx::FromRow)]
struct ArtistBriefRow {
id: i64,
name: String,
}
#[derive(sqlx::FromRow)]
struct TrackRow {
id: i64,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct TrackArtistRow {
track_id: i64,
artist_id: i64,
artist_name: String,
role: String,
}
#[derive(sqlx::FromRow)]
struct MediaFileRow {
file_path: String,
mime_type: String,
file_size_bytes: i64,
}
#[derive(sqlx::FromRow)]
struct PlaybackStateRow {
current_track_id: Option<i64>,
position_ms: i32,
queue_json: String,
queue_position: i32,
shuffle: bool,
repeat_mode: String,
volume: f64,
}
#[derive(sqlx::FromRow)]
struct PlaylistRow {
id: i64,
title: String,
track_count: i64,
is_own: bool,
}
#[derive(sqlx::FromRow)]
struct PlaylistInfoRow {
id: i64,
title: String,
description: Option<String>,
owner_id: i64,
}
#[derive(sqlx::FromRow)]
struct PlaylistTrackRow {
id: i64,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct AppearanceTrackRow {
id: i64,
title: String,
release_id: i64,
release_title: String,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct SearchArtistRow {
id: i64,
name: String,
image_file_id: Option<i64>,
release_count: i64,
track_count: i64,
}
#[derive(sqlx::FromRow)]
struct SearchReleaseRow {
id: i64,
title: String,
release_type: String,
year: Option<i32>,
cover_file_id: Option<i64>,
track_count: i64,
}
#[derive(sqlx::FromRow)]
struct SearchTrackRow {
id: i64,
title: String,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
cover_file_id: Option<i64>,
release_cover_file_id: Option<i64>,
}
#[derive(sqlx::FromRow)]
struct PlayHistoryRow {
id: i64,
track_id: i64,
track_title: String,
release_title: Option<String>,
played_at: String,
duration_listened: Option<i32>,
completed: bool,
}
#[derive(sqlx::FromRow)]
struct ReleaseInfoRow {
id: i64,
title: String,
release_type: String,
year: Option<i32>,
cover_file_id: Option<i64>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn cover_url(file_id: Option<i64>) -> Option<String> {
file_id.map(|id| format!("/api/player/cover/{id}"))
}
fn track_cover_url(track_cover: Option<i64>, release_cover: Option<i64>) -> Option<String> {
cover_url(track_cover.or(release_cover))
}
// ---------------------------------------------------------------------------
// SPA shell
// ---------------------------------------------------------------------------
@@ -643,6 +228,11 @@ async fn artist_detail_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
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()))?;
let release_cards: Vec<ReleaseCard> = releases
.into_iter()
.map(|r| ReleaseCard {
@@ -652,6 +242,7 @@ async fn artist_detail_handler(
year: r.year,
cover_url: cover_url(r.cover_file_id),
track_count: r.track_count,
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
})
.collect();
@@ -676,10 +267,17 @@ async fn artist_detail_handler(
r.title::text AS release_title,
t.duration_seconds,
t.cover_file_id,
r.cover_file_id AS release_cover_file_id
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
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
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE ta.artist_id = $1
AND ta.role = 'featuring'
AND t.is_hidden = false
@@ -745,6 +343,12 @@ async fn artist_detail_handler(
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}"),
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,
}
})
.collect();
@@ -807,9 +411,16 @@ async fn release_detail_handler(
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,
r.cover_file_id as release_cover_file_id
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
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.release_id = $1 AND t.is_hidden = false
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST"#,
)
@@ -875,9 +486,20 @@ async fn release_detail_handler(
featured_artists: track_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}"),
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,
}
})
.collect();
let uploaders = load_release_uploaders(pool, &[release.id])
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
.remove(&release.id)
.unwrap_or_default();
Json(ReleaseDetail {
id: release.id,
@@ -893,6 +515,7 @@ async fn release_detail_handler(
})
.collect(),
tracks: track_items,
uploaders,
})
.into_response()
}
@@ -988,10 +611,17 @@ async fn playlist_detail_handler(
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,
r.cover_file_id as release_cover_file_id
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
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
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE pt.playlist_id = $1 AND t.is_hidden = false
ORDER BY pt.position"#,
)
@@ -1073,6 +703,12 @@ async fn build_track_items(
featured_artists: track_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}"),
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,
}
})
.collect())
@@ -1086,10 +722,17 @@ async fn likes_playlist_handler(
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,
r.cover_file_id as release_cover_file_id
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
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
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE ult.user_id = $1 AND t.is_hidden = false
ORDER BY ult.created_at DESC"#,
)
@@ -1543,9 +1186,16 @@ async fn search_handler(
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,
rel.cover_file_id AS release_cover_file_id
rel.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
FROM furumusic__track t
JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.is_hidden = false AND t.title_sort ILIKE '%' || $1 || '%'
ORDER BY t.title_sort LIMIT $2"#,
)
@@ -1605,22 +1255,32 @@ async fn search_handler(
.fetch_all(pool);
let t = sqlx::query_as::<_, SearchTrackRow>(
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, release_cover_file_id FROM (
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
release_cover_file_id, uploader_name, audio_format, audio_bitrate,
audio_sample_rate, audio_bit_depth, file_size_bytes FROM (
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,
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,
MAX(sim) AS similarity
FROM (
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id,
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
similarity(title_sort, $1) AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
UNION ALL
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id,
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
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
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id,
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
ORDER BY similarity DESC
LIMIT $2
) sub"#,
@@ -1685,6 +1345,11 @@ async fn search_handler(
})
.collect();
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()))?;
let releases: Vec<ReleaseCard> = release_rows
.into_iter()
.map(|r| ReleaseCard {
@@ -1694,6 +1359,7 @@ async fn search_handler(
year: r.year,
cover_url: cover_url(r.cover_file_id),
track_count: r.track_count,
uploaders: release_uploaders.remove(&r.id).unwrap_or_default(),
})
.collect();
@@ -1711,6 +1377,12 @@ async fn search_handler(
featured_artists: track_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}"),
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,
}
})
.collect();
@@ -2265,9 +1937,16 @@ async fn tracks_by_ids_handler(
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,
r.cover_file_id as release_cover_file_id
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
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE t.id = ANY($1) AND t.is_hidden = false"#,
)
.bind(&ids)
@@ -2332,6 +2011,12 @@ async fn tracks_by_ids_handler(
featured_artists: track_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}"),
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,
},
);
}
@@ -2448,8 +2133,7 @@ impl App for PlayerApp {
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 {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
@@ -2466,6 +2150,7 @@ impl App for PlayerApp {
&path.0.id,
json.0.selected_files,
live_config.agent_inbox_dir,
user.id,
)
.await
{