Compare commits
7 Commits
64e20136f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 181f736f25 | |||
| 8045dadc57 | |||
| dcf4993f68 | |||
| cf5a38a376 | |||
| e4947191d0 | |||
| b4e0756a90 | |||
| c425402857 |
@@ -0,0 +1,59 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Drop the unique index on artist name — different artists can share a name
|
||||||
|
// (e.g., "Clara" the Italian singer and "Clara" the Brazilian singer)
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_artists_name_unique")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Replace with a non-unique index for lookup performance
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_artists_name")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.col(Artists::Name)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_index(
|
||||||
|
Index::drop()
|
||||||
|
.name("idx_artists_name")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_artists_name_unique")
|
||||||
|
.table(Artists::Table)
|
||||||
|
.col(Artists::Name)
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Artists {
|
||||||
|
Table,
|
||||||
|
Name,
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ mod m20260320_000015_add_subsonic_password;
|
|||||||
mod m20260323_000016_remove_orphaned_artists;
|
mod m20260323_000016_remove_orphaned_artists;
|
||||||
mod m20260323_000017_create_work_queue_and_scheduler;
|
mod m20260323_000017_create_work_queue_and_scheduler;
|
||||||
mod m20260324_000018_add_track_tagged;
|
mod m20260324_000018_add_track_tagged;
|
||||||
|
mod m20260325_000019_allow_duplicate_artist_names;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260323_000016_remove_orphaned_artists::Migration),
|
Box::new(m20260323_000016_remove_orphaned_artists::Migration),
|
||||||
Box::new(m20260323_000017_create_work_queue_and_scheduler::Migration),
|
Box::new(m20260323_000017_create_work_queue_and_scheduler::Migration),
|
||||||
Box::new(m20260324_000018_add_track_tagged::Migration),
|
Box::new(m20260324_000018_add_track_tagged::Migration),
|
||||||
|
Box::new(m20260325_000019_allow_duplicate_artist_names::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use sea_orm::sea_query::Expr;
|
||||||
use sea_orm::*;
|
use sea_orm::*;
|
||||||
|
|
||||||
use crate::entities::album::{self, ActiveModel, Entity as Albums, Model as Album};
|
use crate::entities::album::{self, ActiveModel, Entity as Albums, Model as Album};
|
||||||
@@ -84,6 +85,131 @@ pub async fn get_by_artist(db: &DatabaseConnection, artist_id: i32) -> DbResult<
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_random(db: &DatabaseConnection, count: u64) -> DbResult<Vec<Album>> {
|
||||||
|
Ok(Albums::find()
|
||||||
|
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
||||||
|
.limit(count)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_newest(db: &DatabaseConnection, limit: u64, offset: u64) -> DbResult<Vec<Album>> {
|
||||||
|
Ok(Albums::find()
|
||||||
|
.order_by_desc(Expr::cust("COALESCE(year, 0)"))
|
||||||
|
.order_by_asc(album::Column::Name)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_year_range(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
from: i32,
|
||||||
|
to: i32,
|
||||||
|
limit: u64,
|
||||||
|
offset: u64,
|
||||||
|
) -> DbResult<Vec<Album>> {
|
||||||
|
let (lo, hi) = if from <= to { (from, to) } else { (to, from) };
|
||||||
|
Ok(Albums::find()
|
||||||
|
.filter(album::Column::Year.gte(lo))
|
||||||
|
.filter(album::Column::Year.lte(hi))
|
||||||
|
.order_by_asc(album::Column::Year)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_by_genre(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
genre: &str,
|
||||||
|
limit: u64,
|
||||||
|
offset: u64,
|
||||||
|
) -> DbResult<Vec<Album>> {
|
||||||
|
use crate::entities::track;
|
||||||
|
|
||||||
|
// Find album IDs that have tracks matching this genre
|
||||||
|
let pattern = format!("%{genre}%");
|
||||||
|
let album_ids: Vec<i32> = track::Entity::find()
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"LOWER(genre) LIKE LOWER(?)",
|
||||||
|
[pattern],
|
||||||
|
))
|
||||||
|
.filter(track::Column::AlbumId.is_not_null())
|
||||||
|
.all(db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|t| t.album_id)
|
||||||
|
.collect::<std::collections::HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if album_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Albums::find()
|
||||||
|
.filter(album::Column::Id.is_in(album_ids))
|
||||||
|
.order_by_asc(album::Column::Name)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_alphabetical_by_artist(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
limit: u64,
|
||||||
|
offset: u64,
|
||||||
|
) -> DbResult<Vec<Album>> {
|
||||||
|
Ok(Albums::find()
|
||||||
|
.order_by_asc(Expr::cust("LOWER(album_artist)"))
|
||||||
|
.order_by_asc(Expr::cust("LOWER(name)"))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_recent(db: &DatabaseConnection, limit: u64, offset: u64) -> DbResult<Vec<Album>> {
|
||||||
|
use crate::entities::track;
|
||||||
|
|
||||||
|
// Find albums ordered by their most recently added track
|
||||||
|
let tracks = track::Entity::find()
|
||||||
|
.filter(track::Column::AlbumId.is_not_null())
|
||||||
|
.order_by_desc(track::Column::AddedAt)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Collect unique album IDs in order of most recent track
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let album_ids: Vec<i32> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|t| t.album_id)
|
||||||
|
.filter(|id| seen.insert(*id))
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit as usize)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if album_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch albums and preserve the ordering
|
||||||
|
let albums = Albums::find()
|
||||||
|
.filter(album::Column::Id.is_in(album_ids.clone()))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let album_map: std::collections::HashMap<i32, Album> =
|
||||||
|
albums.into_iter().map(|a| (a.id, a)).collect();
|
||||||
|
Ok(album_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|id| album_map.get(&id).cloned())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbResult<Album> {
|
pub async fn update(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbResult<Album> {
|
||||||
let mut active = model;
|
let mut active = model;
|
||||||
active.id = Set(id);
|
active.id = Set(id);
|
||||||
|
|||||||
+22
-2
@@ -21,14 +21,19 @@ pub async fn upsert(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(existing) = find_by_name(db, name).await? {
|
if let Some(existing) = find_by_name(db, name).await? {
|
||||||
// Update musicbrainz_id if we have one now and didn't before
|
|
||||||
if musicbrainz_id.is_some() && existing.musicbrainz_id.is_none() {
|
if musicbrainz_id.is_some() && existing.musicbrainz_id.is_none() {
|
||||||
|
// We have an MBID now and the existing record doesn't — update it
|
||||||
let mut active: ActiveModel = existing.into();
|
let mut active: ActiveModel = existing.into();
|
||||||
active.musicbrainz_id = Set(musicbrainz_id.map(String::from));
|
active.musicbrainz_id = Set(musicbrainz_id.map(String::from));
|
||||||
return Ok(active.update(db).await?);
|
return Ok(active.update(db).await?);
|
||||||
}
|
}
|
||||||
|
if musicbrainz_id.is_none() || existing.musicbrainz_id.as_deref() == musicbrainz_id {
|
||||||
|
// No MBID provided, or MBIDs match — return existing
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
|
// MBIDs differ — this is a different artist with the same name.
|
||||||
|
// Fall through to insert a new record.
|
||||||
|
}
|
||||||
|
|
||||||
// Try to insert — if we race with another task, catch the unique constraint
|
// Try to insert — if we race with another task, catch the unique constraint
|
||||||
// violation and fall back to a lookup.
|
// violation and fall back to a lookup.
|
||||||
@@ -48,7 +53,15 @@ pub async fn upsert(
|
|||||||
Err(DbErr::Exec(RuntimeErr::SqlxError(sqlx_err)))
|
Err(DbErr::Exec(RuntimeErr::SqlxError(sqlx_err)))
|
||||||
if sqlx_err.to_string().contains("UNIQUE constraint failed") =>
|
if sqlx_err.to_string().contains("UNIQUE constraint failed") =>
|
||||||
{
|
{
|
||||||
// Lost the race — another task inserted first, just look it up
|
// Lost the race on MBID unique constraint — look up by MBID first, then name
|
||||||
|
if let Some(mbid) = musicbrainz_id
|
||||||
|
&& let Some(existing) = Artists::find()
|
||||||
|
.filter(artist::Column::MusicbrainzId.eq(mbid))
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
find_by_name(db, name)
|
find_by_name(db, name)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DbError::Other(format!("artist '{name}' vanished after conflict")))
|
.ok_or_else(|| DbError::Other(format!("artist '{name}' vanished after conflict")))
|
||||||
@@ -80,6 +93,13 @@ pub async fn list(db: &DatabaseConnection, limit: u64, offset: u64) -> DbResult<
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_all(db: &DatabaseConnection) -> DbResult<Vec<Artist>> {
|
||||||
|
Ok(Artists::find()
|
||||||
|
.order_by_asc(Expr::cust("LOWER(name)"))
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbResult<Artist> {
|
pub async fn update(db: &DatabaseConnection, id: i32, model: ActiveModel) -> DbResult<Artist> {
|
||||||
let mut active = model;
|
let mut active = model;
|
||||||
active.id = Set(id);
|
active.id = Set(id);
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ pub async fn get_by_path(db: &DatabaseConnection, file_path: &str) -> DbResult<O
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_mbid(db: &DatabaseConnection, mbid: &str) -> DbResult<Vec<Track>> {
|
||||||
|
Ok(Tracks::find()
|
||||||
|
.filter(track::Column::MusicbrainzId.eq(mbid))
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list(db: &DatabaseConnection, limit: u64, offset: u64) -> DbResult<Vec<Track>> {
|
pub async fn list(db: &DatabaseConnection, limit: u64, offset: u64) -> DbResult<Vec<Track>> {
|
||||||
Ok(Tracks::find()
|
Ok(Tracks::find()
|
||||||
.order_by_asc(track::Column::Artist)
|
.order_by_asc(track::Column::Artist)
|
||||||
@@ -87,6 +94,13 @@ pub async fn get_by_artist(db: &DatabaseConnection, artist_id: i32) -> DbResult<
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn count_by_artist(db: &DatabaseConnection, artist_id: i32) -> DbResult<u64> {
|
||||||
|
Ok(Tracks::find()
|
||||||
|
.filter(track::Column::ArtistId.eq(artist_id))
|
||||||
|
.count(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get tracks that need metadata enrichment — either no MBID at all,
|
/// Get tracks that need metadata enrichment — either no MBID at all,
|
||||||
/// or have an MBID but are missing album info (e.g., freshly downloaded).
|
/// or have an MBID but are missing album info (e.g., freshly downloaded).
|
||||||
pub async fn get_needing_metadata(db: &DatabaseConnection) -> DbResult<Vec<Track>> {
|
pub async fn get_needing_metadata(db: &DatabaseConnection) -> DbResult<Vec<Track>> {
|
||||||
@@ -157,6 +171,54 @@ pub async fn get_random(db: &DatabaseConnection, count: u64) -> DbResult<Vec<Tra
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get random tracks with optional genre and year range filters.
|
||||||
|
pub async fn get_random_filtered(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
count: u64,
|
||||||
|
genre: Option<&str>,
|
||||||
|
from_year: Option<i32>,
|
||||||
|
to_year: Option<i32>,
|
||||||
|
) -> DbResult<Vec<Track>> {
|
||||||
|
let mut query = Tracks::find();
|
||||||
|
if let Some(g) = genre {
|
||||||
|
let pattern = format!("%{g}%");
|
||||||
|
query = query.filter(Expr::cust_with_values(
|
||||||
|
"LOWER(genre) LIKE LOWER(?)",
|
||||||
|
[pattern],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(y) = from_year {
|
||||||
|
query = query.filter(track::Column::Year.gte(y));
|
||||||
|
}
|
||||||
|
if let Some(y) = to_year {
|
||||||
|
query = query.filter(track::Column::Year.lte(y));
|
||||||
|
}
|
||||||
|
Ok(query
|
||||||
|
.order_by(Expr::cust("RANDOM()"), Order::Asc)
|
||||||
|
.limit(count)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get tracks matching a genre with pagination.
|
||||||
|
pub async fn get_by_genre_paginated(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
genre: &str,
|
||||||
|
limit: u64,
|
||||||
|
offset: u64,
|
||||||
|
) -> DbResult<Vec<Track>> {
|
||||||
|
let pattern = format!("%{genre}%");
|
||||||
|
Ok(Tracks::find()
|
||||||
|
.filter(Expr::cust_with_values(
|
||||||
|
"LOWER(genre) LIKE LOWER(?)",
|
||||||
|
[pattern],
|
||||||
|
))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.all(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get tracks added within the last N days.
|
/// Get tracks added within the last N days.
|
||||||
pub async fn get_recent(db: &DatabaseConnection, days: u32, limit: u64) -> DbResult<Vec<Track>> {
|
pub async fn get_recent(db: &DatabaseConnection, days: u32, limit: u64) -> DbResult<Vec<Track>> {
|
||||||
let cutoff = Utc::now().naive_utc() - chrono::Duration::days(i64::from(days));
|
let cutoff = Utc::now().naive_utc() - chrono::Duration::days(i64::from(days));
|
||||||
@@ -184,6 +246,35 @@ pub async fn delete_by_artist(db: &DatabaseConnection, artist_id: i32) -> DbResu
|
|||||||
Ok(result.rows_affected)
|
Ok(result.rows_affected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get tracks that have been tagged but have no corresponding wanted_item.
|
||||||
|
/// These are files that went through the pipeline but aren't part of any watched content.
|
||||||
|
pub async fn get_unwanted(db: &DatabaseConnection) -> DbResult<Vec<Track>> {
|
||||||
|
let all_tagged = Tracks::find()
|
||||||
|
.filter(track::Column::Tagged.eq(true))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let all_wanted = crate::entities::wanted_item::Entity::find().all(db).await?;
|
||||||
|
|
||||||
|
let wanted_mbids: std::collections::HashSet<&str> = all_wanted
|
||||||
|
.iter()
|
||||||
|
.filter_map(|w| w.musicbrainz_id.as_deref())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let wanted_track_ids: std::collections::HashSet<i32> =
|
||||||
|
all_wanted.iter().filter_map(|w| w.track_id).collect();
|
||||||
|
|
||||||
|
Ok(all_tagged
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| {
|
||||||
|
// Not linked by track_id
|
||||||
|
!wanted_track_ids.contains(&t.id)
|
||||||
|
// Not linked by MBID
|
||||||
|
&& !t.musicbrainz_id.as_deref().is_some_and(|mbid| wanted_mbids.contains(mbid))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete tracks whose files no longer exist on disk.
|
/// Delete tracks whose files no longer exist on disk.
|
||||||
pub async fn delete_orphaned(db: &DatabaseConnection) -> DbResult<u64> {
|
pub async fn delete_orphaned(db: &DatabaseConnection) -> DbResult<u64> {
|
||||||
let all = Tracks::find().all(db).await?;
|
let all = Tracks::find().all(db).await?;
|
||||||
|
|||||||
@@ -69,6 +69,30 @@ pub async fn update_status(
|
|||||||
Ok(active.update(db).await?)
|
Ok(active.update(db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_mbid(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
musicbrainz_id: &str,
|
||||||
|
) -> DbResult<WantedItem> {
|
||||||
|
let existing = get_by_id(db, id).await?;
|
||||||
|
let mut active: ActiveModel = existing.into();
|
||||||
|
active.musicbrainz_id = Set(Some(musicbrainz_id.to_string()));
|
||||||
|
active.updated_at = Set(Utc::now().naive_utc());
|
||||||
|
Ok(active.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_track_id(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
id: i32,
|
||||||
|
track_id: i32,
|
||||||
|
) -> DbResult<WantedItem> {
|
||||||
|
let existing = get_by_id(db, id).await?;
|
||||||
|
let mut active: ActiveModel = existing.into();
|
||||||
|
active.track_id = Set(Some(track_id));
|
||||||
|
active.updated_at = Set(Utc::now().naive_utc());
|
||||||
|
Ok(active.update(db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_by_mbid(
|
pub async fn find_by_mbid(
|
||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
musicbrainz_id: &str,
|
musicbrainz_id: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user