Compare commits

..
2 Commits
Author SHA1 Message Date
Ultradesu b0d8929b4c Fixed liked track sorting
Build and Publish / Build and Publish Docker Image (push) Successful in 10m14s
2026-07-27 17:09:59 +01:00
Ultradesu 291265be7d hotfix search
Build and Publish / Build and Publish Docker Image (push) Successful in 7m32s
2026-07-27 17:02:20 +01:00
6 changed files with 128 additions and 23 deletions
Generated
+1 -1
View File
@@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.8.4" version = "0.9.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.9.0" version = "0.9.2"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+9 -2
View File
@@ -1712,7 +1712,7 @@ async fn local_seed_needs_metadata_backfill(pool: &sqlx::PgPool, user_id: i64) -
async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> { async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
let now = now_ms(); let now = now_ms();
let like_rows = sqlx::query( let like_rows = sqlx::query(
"SELECT ult.track_id, c.content_id "SELECT ult.track_id, ult.created_at, c.content_id
FROM furumusic__user_liked_track ult FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id JOIN furumusic__track t ON t.id = ult.track_id
JOIN furumusic__media_file m ON m.id = t.audio_file_id JOIN furumusic__media_file m ON m.id = t.audio_file_id
@@ -1729,6 +1729,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
continue; continue;
}; };
let track_id: i64 = row.get("track_id"); let track_id: i64 = row.get("track_id");
let liked_hlc_ms = timestamp_from_iso(&row.get::<String, _>("created_at")).unwrap_or(now);
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id) let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
.await? .await?
.map(serde_json::to_value) .map(serde_json::to_value)
@@ -1743,7 +1744,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
) )
.bind(user_id) .bind(user_id)
.bind(&content_id) .bind(&content_id)
.bind(now) .bind(liked_hlc_ms)
.bind(format!("local_seed:like:{track_id}")) .bind(format!("local_seed:like:{track_id}"))
.bind(track_id) .bind(track_id)
.bind(fed_json) .bind(fed_json)
@@ -4536,6 +4537,12 @@ fn iso_from_ms(ms: i64) -> String {
.to_string() .to_string()
} }
fn timestamp_from_iso(value: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(value)
.ok()
.map(|value| value.timestamp_millis())
}
fn now_label() -> String { fn now_label() -> String {
let secs = (now_ms() / 1000).max(0); let secs = (now_ms() / 1000).max(0);
format!( format!(
+2
View File
@@ -78,6 +78,8 @@ pub(super) struct TrackItem {
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlaylistTrackItem { pub(super) struct PlaylistTrackItem {
pub(super) playlist_track_id: Option<i64>, pub(super) playlist_track_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) sort_key: Option<i64>,
#[serde(flatten)] #[serde(flatten)]
pub(super) track: TrackItem, pub(super) track: TrackItem,
} }
+82 -15
View File
@@ -4111,6 +4111,7 @@ async fn build_playlist_track_items(
.zip(playlist_track_ids) .zip(playlist_track_ids)
.map(|(track, playlist_track_id)| PlaylistTrackItem { .map(|(track, playlist_track_id)| PlaylistTrackItem {
playlist_track_id, playlist_track_id,
sort_key: None,
track, track,
}) })
.collect()) .collect())
@@ -4309,14 +4310,50 @@ async fn likes_playlist_handler(
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(|e| cot::Error::internal(e.to_string()))?;
let track_items = build_track_items(tracks, pool) let order_rows = sqlx::query(
"SELECT ult.track_id,
COALESCE(
fsl.hlc_ms,
(EXTRACT(EPOCH FROM ult.created_at::timestamptz) * 1000)::bigint,
0
) AS sort_key
FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id
LEFT JOIN furumusic__media_file m ON m.id = t.audio_file_id
LEFT JOIN furumusic__federation_content_id_cache c
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
LEFT JOIN furumusic__fed_state_like fsl
ON fsl.user_id = ult.user_id
AND fsl.content_id = c.content_id
AND fsl.liked = true
WHERE ult.user_id = $1",
)
.bind(user_id)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let order_by_track = order_rows
.into_iter()
.map(|row| (row.get::<i64, _>("track_id"), row.get::<i64, _>("sort_key")))
.collect::<HashMap<_, _>>();
let mut track_items = build_track_items(tracks, pool)
.await? .await?
.into_iter() .into_iter()
.map(|track| PlaylistTrackItem { .map(|track| PlaylistTrackItem {
playlist_track_id: None, playlist_track_id: None,
sort_key: order_by_track.get(&track.id).copied(),
track, track,
}) })
.collect(); .collect::<Vec<_>>();
track_items.sort_by(|left, right| {
right
.sort_key
.cmp(&left.sort_key)
.then_with(|| left.track.content_id.cmp(&right.track.content_id))
.then_with(|| left.track.title.cmp(&right.track.title))
.then_with(|| left.track.id.cmp(&right.track.id))
});
Json(PlaylistDetail { Json(PlaylistDetail {
id: -1, id: -1,
@@ -5759,6 +5796,10 @@ async fn search_handler(
}) })
.into_response(); .into_response();
} }
let normalized_q = match music_dht::normalize_name(&q) {
normalized if !normalized.is_empty() => normalized,
_ => q.clone(),
};
let limit = query.0.limit.unwrap_or(10).clamp(1, 50) as i64; let limit = query.0.limit.unwrap_or(10).clamp(1, 50) as i64;
let short = q.chars().count() < 3; let short = q.chars().count() < 3;
@@ -5774,11 +5815,14 @@ async fn search_handler(
JOIN furumusic__track t ON t.release_id = r.id AND t.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 WHERE ra.artist_id = a.id), 0) AS track_count
FROM furumusic__artist a FROM furumusic__artist a
WHERE a.is_hidden = false AND a.name_sort ILIKE '%' || $1 || '%' WHERE a.is_hidden = false
AND (a.name_sort ILIKE '%' || $1 || '%'
OR a.name_sort ILIKE '%' || $3 || '%')
ORDER BY a.name_sort LIMIT $2"#, ORDER BY a.name_sort LIMIT $2"#,
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
let r = sqlx::query_as::<_, SearchReleaseRow>( let r = sqlx::query_as::<_, SearchReleaseRow>(
@@ -5786,11 +5830,14 @@ async fn search_handler(
r.year, r.cover_file_id, 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 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 FROM furumusic__release r
WHERE r.is_hidden = false AND r.title_sort ILIKE '%' || $1 || '%' WHERE r.is_hidden = false
AND (r.title_sort ILIKE '%' || $1 || '%'
OR r.title_sort ILIKE '%' || $3 || '%')
ORDER BY r.title_sort LIMIT $2"#, ORDER BY r.title_sort LIMIT $2"#,
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
let t = sqlx::query_as::<_, SearchTrackRow>( let t = sqlx::query_as::<_, SearchTrackRow>(
@@ -5813,11 +5860,14 @@ async fn search_handler(
FROM furumusic__track t FROM furumusic__track t
JOIN furumusic__release rel ON rel.id = t.release_id JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_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 || '%' WHERE t.is_hidden = false
AND (t.title_sort ILIKE '%' || $1 || '%'
OR t.title_sort ILIKE '%' || $3 || '%')
ORDER BY t.title_sort LIMIT $2"#, ORDER BY t.title_sort LIMIT $2"#,
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))? tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
@@ -5834,11 +5884,16 @@ async fn search_handler(
WHERE ra.artist_id = a.id), 0) AS track_count, WHERE ra.artist_id = a.id), 0) AS track_count,
MAX(sim) AS similarity MAX(sim) AS similarity
FROM ( FROM (
SELECT id, name, image_file_id, name_sort, similarity(name_sort, $1) AS sim SELECT id, name, image_file_id, name_sort,
FROM furumusic__artist WHERE is_hidden = false AND name_sort % $1 GREATEST(similarity(name_sort, $1), similarity(name_sort, $3)) AS sim
FROM furumusic__artist
WHERE is_hidden = false AND (name_sort % $1 OR name_sort % $3)
UNION ALL UNION ALL
SELECT id, name, image_file_id, name_sort, 0.01::real AS sim 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 || '%' FROM furumusic__artist
WHERE is_hidden
= false AND (name_sort ILIKE '%' || $1 || '%'
OR name_sort ILIKE '%' || $3 || '%')
) a ) a
GROUP BY a.id, a.name, a.image_file_id GROUP BY a.id, a.name, a.image_file_id
ORDER BY similarity DESC ORDER BY similarity DESC
@@ -5847,6 +5902,7 @@ async fn search_handler(
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
let r = sqlx::query_as::<_, SearchReleaseRow>( let r = sqlx::query_as::<_, SearchReleaseRow>(
@@ -5856,11 +5912,16 @@ async fn search_handler(
COALESCE((SELECT COUNT(*) FROM furumusic__track t WHERE t.release_id = r.id AND t.is_hidden = false), 0) AS track_count, 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 MAX(sim) AS similarity
FROM ( FROM (
SELECT id, title, release_type, year, cover_file_id, title_sort, similarity(title_sort, $1) AS sim SELECT id, title, release_type, year, cover_file_id, title_sort,
FROM furumusic__release WHERE is_hidden = false AND title_sort % $1 GREATEST(similarity(title_sort, $1), similarity(title_sort, $3)) AS sim
FROM furumusic__release
WHERE is_hidden = false AND (title_sort % $1 OR title_sort % $3)
UNION ALL UNION ALL
SELECT id, title, release_type, year, cover_file_id, title_sort, 0.01::real AS sim 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 || '%' FROM furumusic__release
WHERE is_hidden = false
AND (title_sort ILIKE '%' || $1 || '%'
OR title_sort ILIKE '%' || $3 || '%')
) r ) r
GROUP BY r.id, r.title, r.release_type, r.year, r.cover_file_id GROUP BY r.id, r.title, r.release_type, r.year, r.cover_file_id
ORDER BY similarity DESC ORDER BY similarity DESC
@@ -5869,6 +5930,7 @@ async fn search_handler(
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
let t = sqlx::query_as::<_, SearchTrackRow>( let t = sqlx::query_as::<_, SearchTrackRow>(
@@ -5895,13 +5957,17 @@ async fn search_handler(
FROM ( FROM (
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
similarity(title_sort, $1) AS sim GREATEST(similarity(title_sort, $1), similarity(title_sort, $3)) AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1 FROM furumusic__track
WHERE is_hidden = false AND (title_sort % $1 OR title_sort % $3)
UNION ALL UNION ALL
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id, SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
0.01::real AS sim 0.01::real AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%' FROM furumusic__track
WHERE is_hidden = false
AND (title_sort ILIKE '%' || $1 || '%'
OR title_sort ILIKE '%' || $3 || '%')
) t ) t
JOIN furumusic__release rel ON rel.id = t.release_id JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
@@ -5914,6 +5980,7 @@ async fn search_handler(
) )
.bind(&q) .bind(&q)
.bind(limit) .bind(limit)
.bind(&normalized_q)
.fetch_all(pool); .fetch_all(pool);
tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))? tokio::try_join!(a, r, t).map_err(|e| cot::Error::internal(e.to_string()))?
@@ -6263,7 +6330,7 @@ async fn federation_playlist_tracks_handler(
}; };
let rows = if path.id == -1 { let rows = if path.id == -1 {
sqlx::query( sqlx::query(
"SELECT content_id, 0::bigint AS position, fed_json "SELECT content_id, hlc_ms AS position, fed_json
FROM furumusic__fed_state_like FROM furumusic__fed_state_like
WHERE user_id = $1 AND liked = true WHERE user_id = $1 AND liked = true
AND local_track_id IS NULL AND fed_json IS NOT NULL AND local_track_id IS NULL AND fed_json IS NOT NULL
+33 -4
View File
@@ -3288,10 +3288,17 @@ document.addEventListener('alpine:init', () => {
if (federationResponse.ok) { if (federationResponse.ok) {
const references = await federationResponse.json(); const references = await federationResponse.json();
const federated = (references || []) const federated = (references || [])
.map(reference => this.federationTrackFromWire(reference.federation)) .map(reference => {
.filter(Boolean) const track = this.federationTrackFromWire(reference.federation);
.map(track => this.federationQueueTrack(track)); if (!track) return null;
return {
...this.federationQueueTrack(track),
sort_key: Number(reference.position || 0),
};
})
.filter(Boolean);
playlist.tracks = [...(playlist.tracks || []), ...federated]; playlist.tracks = [...(playlist.tracks || []), ...federated];
if (Number(id) === -1) playlist.tracks = this.sortLikeTracks(playlist.tracks);
} }
this.currentPlaylist = playlist; this.currentPlaylist = playlist;
} catch {} } catch {}
@@ -3777,7 +3784,13 @@ document.addEventListener('alpine:init', () => {
if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) { if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) {
this.currentPlaylist = { this.currentPlaylist = {
...this.currentPlaylist, ...this.currentPlaylist,
tracks: [...this.currentPlaylist.tracks, this.federationQueueTrack(track)], tracks: this.sortLikeTracks([
...this.currentPlaylist.tracks,
{
...this.federationQueueTrack(track),
sort_key: Date.now(),
},
]),
}; };
} else if (!liked) { } else if (!liked) {
this.currentPlaylist = { this.currentPlaylist = {
@@ -3788,6 +3801,22 @@ document.addEventListener('alpine:init', () => {
} }
}, },
sortLikeTracks(tracks) {
return [...(tracks || [])].sort((left, right) => {
const order = Number(right.sort_key || 0) - Number(left.sort_key || 0);
if (order) return order;
const stable = String(left.content_id || left.id || '')
.localeCompare(String(right.content_id || right.id || ''));
if (stable) return stable;
const title = String(left.title || '').localeCompare(
String(right.title || ''),
undefined,
{ sensitivity: 'base' },
);
return title;
});
},
isMixedReleaseLiked(release) { isMixedReleaseLiked(release) {
const tracks = release?.tracks || []; const tracks = release?.tracks || [];
if (!tracks.length) return false; if (!tracks.length) return false;