Files
furumusic/src/music/mod.rs
T
ab e1a4b6267f
Build and Publish / Build and Publish Docker Image (push) Successful in 3m5s
PLAYER: Added generated playlists feature
2026-05-29 17:04:30 +03:00

1896 lines
70 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Music library models and migrations.
///
/// This module contains all database models related to the music library:
/// content (files, artists, releases, tracks, genres), user interactions
/// (likes, follows, playlists, play history, playback state), and the
/// AI-agent processing queue.
use cot::db::{Auto, Database, LimitedString, Model};
// ---------------------------------------------------------------------------
// MediaFile — audio files and cover art on disk
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct MediaFile {
#[model(primary_key)]
pub id: Auto<i64>,
/// "audio" or "cover_art"
pub file_type: LimitedString<32>,
/// Relative path on disk from the media root
pub file_path: String,
/// Original filename as uploaded
pub original_filename: LimitedString<255>,
/// MIME type, e.g. "audio/flac", "image/jpeg"
pub mime_type: LimitedString<100>,
/// File size in bytes
pub file_size_bytes: i64,
/// SHA-256 hex digest for dedup
pub sha256_hash: LimitedString<64>,
// Audio-specific fields (NULL for non-audio files)
/// e.g. "mp3", "flac", "ogg", "wav"
pub audio_format: Option<String>,
/// Bitrate in kbps
pub audio_bitrate: Option<i32>,
/// Sample rate in Hz
pub audio_sample_rate: Option<i32>,
/// Bit depth (16, 24, 32)
pub audio_bit_depth: Option<i32>,
/// FK -> user who imported/uploaded the source, NULL when unknown.
pub uploaded_by_user_id: Option<i64>,
/// Stable display label for the uploader. Unknown uploads are stored as "UFO".
pub uploader_name: LimitedString<255>,
pub created_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// Artist
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct Artist {
#[model(primary_key)]
pub id: Auto<i64>,
/// Canonical display name
pub name: LimitedString<255>,
/// Normalized for search/dedup (lowercase, stripped)
pub name_sort: LimitedString<255>,
/// FK → media_file (artist image), nullable
pub image_file_id: Option<i64>,
pub is_hidden: bool,
/// NULL = human-created, non-NULL = LLM model that created it
pub model_name: Option<String>,
pub created_at: LimitedString<32>,
pub updated_at: LimitedString<32>,
}
fn now_iso() -> LimitedString<32> {
LimitedString::new(&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()).unwrap()
}
fn normalize_name(name: &str) -> String {
name.trim().to_lowercase()
}
impl Artist {
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
pub async fn get_by_id(db: &Database, artist_id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(artist_id)).await
}
pub async fn create(
db: &Database,
name: &str,
model_name: Option<&str>,
) -> cot::db::Result<Self> {
let now = now_iso();
let mut artist = Self {
id: Auto::auto(),
name: LimitedString::new(name).unwrap(),
name_sort: LimitedString::new(&normalize_name(name)).unwrap(),
image_file_id: None,
is_hidden: false,
model_name: model_name.map(str::to_owned),
created_at: now.clone(),
updated_at: now,
};
artist.insert(db).await?;
Ok(artist)
}
pub async fn update_name(&mut self, db: &Database, name: &str) -> cot::db::Result<()> {
self.name = LimitedString::new(name).unwrap();
self.name_sort = LimitedString::new(&normalize_name(name)).unwrap();
self.updated_at = now_iso();
self.save(db).await
}
pub async fn set_image_file_id(
&mut self,
db: &Database,
file_id: Option<i64>,
) -> cot::db::Result<()> {
self.image_file_id = file_id;
self.updated_at = now_iso();
self.save(db).await
}
pub async fn delete_by_id(db: &Database, artist_id: i64) -> cot::db::Result<()> {
cot::db::query!(Artist, $id == Auto::Fixed(artist_id))
.delete(db)
.await?;
Ok(())
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
pub fn name_str(&self) -> &str {
&self.name
}
pub fn is_hidden(&self) -> bool {
self.is_hidden
}
}
// ---------------------------------------------------------------------------
// Release (album / single / EP / etc.)
// ---------------------------------------------------------------------------
pub const RELEASE_TYPES: &[(&str, &str, &str)] = &[
("album", "Album", "Альбом"),
("single", "Single", "Сингл"),
("ep", "EP", "EP"),
("compilation", "Compilation", "Сборник"),
("mixtape", "Mixtape", "Микстейп"),
("live", "Live", "Концерт"),
("soundtrack", "Soundtrack", "Саундтрек"),
("remix", "Remix", "Ремикс"),
("demo", "Demo", "Демо"),
];
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct Release {
#[model(primary_key)]
pub id: Auto<i64>,
pub title: LimitedString<255>,
/// Normalized for search/dedup
pub title_sort: LimitedString<255>,
/// One of: album, single, ep, compilation, mixtape, live, soundtrack, remix, demo
pub release_type: LimitedString<32>,
pub year: Option<i32>,
/// FK → media_file (cover art), nullable
pub cover_file_id: Option<i64>,
pub total_tracks: Option<i32>,
pub total_discs: Option<i32>,
pub is_hidden: bool,
/// NULL = human-created, non-NULL = LLM model that created it
pub model_name: Option<String>,
pub created_at: LimitedString<32>,
pub updated_at: LimitedString<32>,
}
#[allow(dead_code)]
impl Release {
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
pub async fn get_by_id(db: &Database, release_id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(release_id)).await
}
pub async fn create(
db: &Database,
title: &str,
release_type: &str,
year: Option<i32>,
model_name: Option<&str>,
) -> cot::db::Result<Self> {
let now = now_iso();
let mut release = Self {
id: Auto::auto(),
title: LimitedString::new(title).unwrap(),
title_sort: LimitedString::new(&normalize_name(title)).unwrap(),
release_type: LimitedString::new(release_type).unwrap(),
year,
cover_file_id: None,
total_tracks: None,
total_discs: None,
is_hidden: false,
model_name: model_name.map(str::to_owned),
created_at: now.clone(),
updated_at: now,
};
release.insert(db).await?;
Ok(release)
}
pub async fn update_fields(
&mut self,
db: &Database,
title: &str,
release_type: &str,
year: Option<i32>,
) -> cot::db::Result<()> {
self.title = LimitedString::new(title).unwrap();
self.title_sort = LimitedString::new(&normalize_name(title)).unwrap();
self.release_type = LimitedString::new(release_type).unwrap();
self.year = year;
self.updated_at = now_iso();
self.save(db).await
}
pub async fn delete_by_id(db: &Database, release_id: i64) -> cot::db::Result<()> {
// Also clean up release_artist links
cot::db::query!(ReleaseArtist, $release_id == release_id)
.delete(db)
.await?;
cot::db::query!(Release, $id == Auto::Fixed(release_id))
.delete(db)
.await?;
Ok(())
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
pub fn title_str(&self) -> &str {
&self.title
}
pub fn release_type_str(&self) -> &str {
&self.release_type
}
pub fn year_val(&self) -> Option<i32> {
self.year
}
pub fn year_display(&self) -> String {
self.year.map(|y| y.to_string()).unwrap_or_default()
}
pub fn is_hidden(&self) -> bool {
self.is_hidden
}
}
// ---------------------------------------------------------------------------
// ReleaseArtist — M2M between releases and artists
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct ReleaseArtist {
#[model(primary_key)]
pub id: Auto<i64>,
pub release_id: i64,
pub artist_id: i64,
/// Display order
pub position: i32,
}
impl ReleaseArtist {
pub async fn find_by_release(db: &Database, release_id: i64) -> cot::db::Result<Vec<Self>> {
cot::db::query!(ReleaseArtist, $release_id == release_id)
.all(db)
.await
}
pub async fn find_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<Vec<Self>> {
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
.all(db)
.await
}
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
cot::db::query!(ReleaseArtist, $artist_id == artist_id)
.count(db)
.await
}
pub async fn set_artists(
db: &Database,
release_id: i64,
artist_ids: &[i64],
) -> cot::db::Result<()> {
// Remove existing links
cot::db::query!(ReleaseArtist, $release_id == release_id)
.delete(db)
.await?;
// Insert new links
for (pos, &aid) in artist_ids.iter().enumerate() {
let mut link = Self {
id: Auto::auto(),
release_id,
artist_id: aid,
position: pos as i32,
};
link.insert(db).await?;
}
Ok(())
}
pub fn artist_id(&self) -> i64 {
self.artist_id
}
pub fn release_id(&self) -> i64 {
self.release_id
}
}
// ---------------------------------------------------------------------------
// Track
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct Track {
#[model(primary_key)]
pub id: Auto<i64>,
pub title: LimitedString<255>,
/// Normalized for search/dedup
pub title_sort: LimitedString<255>,
/// FK → release
pub release_id: i64,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
/// Duration in seconds (float stored as f64)
pub duration_seconds: f64,
/// FK → media_file (audio)
pub audio_file_id: i64,
/// FK → media_file (cover art), nullable — falls back to release cover
pub cover_file_id: Option<i64>,
pub year: Option<i32>,
pub is_hidden: bool,
/// NULL = human-created, non-NULL = LLM model that created it
pub model_name: Option<String>,
pub created_at: LimitedString<32>,
pub updated_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// TrackArtist — M2M between tracks and artists (with role)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct TrackArtist {
#[model(primary_key)]
pub id: Auto<i64>,
pub track_id: i64,
pub artist_id: i64,
/// "main", "featuring", "remixer", "producer"
pub role: LimitedString<32>,
/// Display order
pub position: i32,
}
impl TrackArtist {
pub async fn count_by_artist(db: &Database, artist_id: i64) -> cot::db::Result<u64> {
cot::db::query!(TrackArtist, $artist_id == artist_id)
.count(db)
.await
}
pub async fn create(
db: &Database,
track_id: i64,
artist_id: i64,
role: &str,
position: i32,
) -> cot::db::Result<Self> {
let mut link = Self {
id: Auto::auto(),
track_id,
artist_id,
role: LimitedString::new(role).unwrap(),
position,
};
link.insert(db).await?;
Ok(link)
}
}
// ---------------------------------------------------------------------------
// Genre
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct Genre {
#[model(primary_key)]
pub id: Auto<i64>,
pub name: LimitedString<100>,
/// Normalized for dedup (lowercase, trimmed)
pub name_normalized: LimitedString<100>,
}
// ---------------------------------------------------------------------------
// TrackGenre — M2M between tracks and genres
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct TrackGenre {
#[model(primary_key)]
pub id: Auto<i64>,
pub track_id: i64,
pub genre_id: i64,
}
// ---------------------------------------------------------------------------
// UserLikedTrack
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct UserLikedTrack {
#[model(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
pub track_id: i64,
pub created_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// UserFollowedArtist
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct UserFollowedArtist {
#[model(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
pub artist_id: i64,
pub created_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// Playlist
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct Playlist {
#[model(primary_key)]
pub id: Auto<i64>,
/// FK → user (owner/creator)
pub owner_id: i64,
pub title: LimitedString<255>,
pub description: Option<String>,
pub is_public: bool,
/// FK → media_file (custom cover), nullable
pub cover_file_id: Option<i64>,
/// FK → playlist (original, if this is a fork), nullable
pub forked_from_id: Option<i64>,
pub created_at: LimitedString<32>,
pub updated_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// PlaylistTrack
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct PlaylistTrack {
#[model(primary_key)]
pub id: Auto<i64>,
pub playlist_id: i64,
pub track_id: i64,
/// Order within the playlist
pub position: i32,
pub added_at: LimitedString<32>,
/// FK → user (who added this track)
pub added_by_user_id: i64,
}
// ---------------------------------------------------------------------------
// SavedPlaylist — user "follows" someone else's playlist
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct SavedPlaylist {
#[model(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
pub playlist_id: i64,
pub saved_at: LimitedString<32>,
}
// ---------------------------------------------------------------------------
// PlayHistory
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct PlayHistory {
#[model(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
pub track_id: i64,
pub played_at: LimitedString<32>,
/// How many seconds the user actually listened
pub duration_listened: Option<i32>,
/// Did the user listen to the end?
pub completed: bool,
}
// ---------------------------------------------------------------------------
// PlaybackState — one per user, current queue + position
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct PlaybackState {
#[model(primary_key)]
pub id: Auto<i64>,
pub user_id: i64,
/// FK → track (currently playing), nullable
pub current_track_id: Option<i64>,
/// Current position in the track, in milliseconds
pub position_ms: i32,
/// JSON array of track IDs
pub queue_json: String,
/// Index of the current track in the queue
pub queue_position: i32,
pub shuffle: bool,
/// "off", "all", "one"
pub repeat_mode: LimitedString<16>,
/// Volume level 0.0 1.0
pub volume: f64,
pub updated_at: LimitedString<32>,
}
impl Track {
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
pub async fn create(
db: &Database,
title: &str,
release_id: i64,
track_number: Option<i32>,
disc_number: Option<i32>,
duration_seconds: f64,
audio_file_id: i64,
year: Option<i32>,
model_name: Option<&str>,
) -> cot::db::Result<Self> {
let now = now_iso();
let mut track = Self {
id: Auto::auto(),
title: LimitedString::new(title).unwrap(),
title_sort: LimitedString::new(&normalize_name(title)).unwrap(),
release_id,
track_number,
disc_number,
duration_seconds,
audio_file_id,
cover_file_id: None,
year,
is_hidden: false,
model_name: model_name.map(str::to_owned),
created_at: now.clone(),
updated_at: now,
};
track.insert(db).await?;
Ok(track)
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
}
#[allow(dead_code)]
impl MediaFile {
pub async fn create(
db: &Database,
file_type: &str,
file_path: &str,
original_filename: &str,
mime_type: &str,
file_size_bytes: i64,
sha256_hash: &str,
audio_format: Option<&str>,
audio_bitrate: Option<i32>,
audio_sample_rate: Option<i32>,
audio_bit_depth: Option<i32>,
uploaded_by_user_id: Option<i64>,
uploader_name: Option<&str>,
) -> cot::db::Result<Self> {
let now = now_iso();
let uploader_name = uploader_name
.filter(|name| !name.trim().is_empty())
.unwrap_or("UFO");
let mut mf = Self {
id: Auto::auto(),
file_type: LimitedString::new(file_type).unwrap(),
file_path: file_path.to_owned(),
original_filename: LimitedString::new(original_filename).unwrap(),
mime_type: LimitedString::new(mime_type).unwrap(),
file_size_bytes,
sha256_hash: LimitedString::new(sha256_hash).unwrap(),
audio_format: audio_format.map(str::to_owned),
audio_bitrate,
audio_sample_rate,
audio_bit_depth,
uploaded_by_user_id,
uploader_name: LimitedString::new(uploader_name).unwrap(),
created_at: now,
};
mf.insert(db).await?;
Ok(mf)
}
pub fn id_val(&self) -> i64 {
self.id.unwrap()
}
pub async fn list_all(db: &Database) -> cot::db::Result<Vec<Self>> {
Self::objects().all(db).await
}
pub async fn get_by_id(db: &Database, id: i64) -> cot::db::Result<Option<Self>> {
Self::get_by_primary_key(db, Auto::Fixed(id)).await
}
pub async fn delete_by_id(db: &Database, id: i64) -> cot::db::Result<()> {
db.raw(&format!(
"DELETE FROM furumusic__media_file WHERE id = {}",
id
))
.await?;
Ok(())
}
pub fn file_type_str(&self) -> &str {
&self.file_type
}
pub fn file_path_str(&self) -> &str {
&self.file_path
}
pub fn original_filename_str(&self) -> &str {
&self.original_filename
}
pub fn mime_type_str(&self) -> &str {
&self.mime_type
}
pub fn sha256_hash_str(&self) -> &str {
&self.sha256_hash
}
pub fn audio_format_str(&self) -> &str {
self.audio_format.as_deref().unwrap_or("")
}
pub fn created_at_str(&self) -> &str {
&self.created_at
}
pub fn file_size_display(&self) -> String {
let bytes = self.file_size_bytes;
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{bytes} B")
}
}
}
// ---------------------------------------------------------------------------
// Migrations
// ---------------------------------------------------------------------------
pub mod db_migrations {
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{DatabaseField, Identifier, LimitedString};
// -- M0006: create furumusic__media_file ----------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0006CreateMediaFile;
impl migrations::Migration for M0006CreateMediaFile {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0006_create_media_file";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0005_oidc_link_indexes",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__media_file"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("file_type"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("file_path"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("original_filename"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("mime_type"),
<LimitedString<100> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("file_size_bytes"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("sha256_hash"),
<LimitedString<64> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("audio_format"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("audio_bitrate"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("audio_sample_rate"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("audio_bit_depth"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0007: create furumusic__artist --------------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0007CreateArtist;
impl migrations::Migration for M0007CreateArtist {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0007_create_artist";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0006_create_media_file",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__artist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("name"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("name_sort"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("image_file_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("is_hidden"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0008: create furumusic__release -------------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0008CreateRelease;
impl migrations::Migration for M0008CreateRelease {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0008_create_release";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0007_create_artist",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__release"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("title"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("title_sort"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("release_type"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE).set_null(true),
Field::new(
Identifier::new("cover_file_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("total_tracks"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("total_discs"), <i32 as DatabaseField>::TYPE)
.set_null(true),
Field::new(Identifier::new("is_hidden"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0009: create furumusic__release_artist ------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0009CreateReleaseArtist;
impl migrations::Migration for M0009CreateReleaseArtist {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0009_create_release_artist";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0008_create_release",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__release_artist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("release_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
])
.build()];
}
// -- M0010: create furumusic__track ---------------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0010CreateTrack;
impl migrations::Migration for M0010CreateTrack {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0010_create_track";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0009_create_release_artist",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__track"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("title"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("title_sort"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("release_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("track_number"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("disc_number"), <i32 as DatabaseField>::TYPE)
.set_null(true),
Field::new(
Identifier::new("duration_seconds"),
<f64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("audio_file_id"),
<i64 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("cover_file_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("year"), <i32 as DatabaseField>::TYPE).set_null(true),
Field::new(Identifier::new("is_hidden"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0011: create furumusic__track_artist --------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0011CreateTrackArtist;
impl migrations::Migration for M0011CreateTrackArtist {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0011_create_track_artist";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0010_create_track",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__track_artist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("role"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
])
.build()];
}
// -- M0012: create furumusic__genre + furumusic__track_genre ---------------
#[derive(Debug, Copy, Clone)]
pub struct M0012CreateGenreTables;
impl migrations::Migration for M0012CreateGenreTables {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0012_create_genre_tables";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0011_create_track_artist",
)];
const OPERATIONS: &'static [Operation] = &[
Operation::create_model()
.table_name(Identifier::new("furumusic__genre"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("name"),
<LimitedString<100> as DatabaseField>::TYPE,
)
.unique(),
Field::new(
Identifier::new("name_normalized"),
<LimitedString<100> as DatabaseField>::TYPE,
),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("furumusic__track_genre"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("genre_id"), <i64 as DatabaseField>::TYPE),
])
.build(),
];
}
// -- M0013: create furumusic__user_liked_track ----------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0013CreateUserLikedTrack;
impl migrations::Migration for M0013CreateUserLikedTrack {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0013_create_user_liked_track";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0012_create_genre_tables",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__user_liked_track"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0014: create furumusic__user_followed_artist ------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0014CreateUserFollowedArtist;
impl migrations::Migration for M0014CreateUserFollowedArtist {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0014_create_user_followed_artist";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0013_create_user_liked_track",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__user_followed_artist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("artist_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0015: create playlist tables ----------------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0015CreatePlaylistTables;
impl migrations::Migration for M0015CreatePlaylistTables {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0015_create_playlist_tables";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0014_create_user_followed_artist",
)];
const OPERATIONS: &'static [Operation] = &[
Operation::create_model()
.table_name(Identifier::new("furumusic__playlist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("owner_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("title"),
<LimitedString<255> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("description"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("is_public"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("cover_file_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("forked_from_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("furumusic__playlist_track"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("playlist_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("position"), <i32 as DatabaseField>::TYPE),
Field::new(
Identifier::new("added_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("added_by_user_id"),
<i64 as DatabaseField>::TYPE,
),
])
.build(),
Operation::create_model()
.table_name(Identifier::new("furumusic__saved_playlist"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("playlist_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("saved_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build(),
];
}
// -- M0016: create furumusic__play_history --------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0016CreatePlayHistory;
impl migrations::Migration for M0016CreatePlayHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0016_create_play_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0015_create_playlist_tables",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__play_history"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(Identifier::new("track_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("played_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("duration_listened"),
<i32 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("completed"), <bool as DatabaseField>::TYPE),
])
.build()];
}
// -- M0017: create furumusic__playback_state ------------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0017CreatePlaybackState;
impl migrations::Migration for M0017CreatePlaybackState {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0017_create_playback_state";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0016_create_play_history",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__playback_state"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("current_track_id"),
<i64 as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("position_ms"), <i32 as DatabaseField>::TYPE),
Field::new(
Identifier::new("queue_json"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("queue_position"),
<i32 as DatabaseField>::TYPE,
),
Field::new(Identifier::new("shuffle"), <bool as DatabaseField>::TYPE),
Field::new(
Identifier::new("repeat_mode"),
<LimitedString<16> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
])
.build()];
}
// -- M0018: create furumusic__processing_task -----------------------------
#[derive(Debug, Copy, Clone)]
pub struct M0018CreateProcessingTask;
impl migrations::Migration for M0018CreateProcessingTask {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0018_create_processing_task";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0017_create_playback_state",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__processing_task"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("status"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("task_type"),
<LimitedString<64> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("input_path"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("context_json"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("result_json"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("error_message"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(Identifier::new("attempts"), <i32 as DatabaseField>::TYPE),
Field::new(
Identifier::new("max_attempts"),
<i32 as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("created_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("updated_at"),
<LimitedString<32> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("started_at"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("completed_at"),
<LimitedString<32> as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
// -- M0019: indexes for all music tables ----------------------------------
#[cot::db::migrations::migration_op]
async fn create_music_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
let stmts = [
// media_file: lookup by hash for dedup
"CREATE INDEX idx_media_file_sha256 ON furumusic__media_file (sha256_hash)",
// media_file: filter by type
"CREATE INDEX idx_media_file_type ON furumusic__media_file (file_type)",
// artist: search by normalized name
"CREATE INDEX idx_artist_name_sort ON furumusic__artist (name_sort)",
// release: search by normalized title
"CREATE INDEX idx_release_title_sort ON furumusic__release (title_sort)",
// release: filter by type
"CREATE INDEX idx_release_type ON furumusic__release (release_type)",
// release_artist: unique pair + lookup
"CREATE UNIQUE INDEX idx_release_artist_uniq ON furumusic__release_artist (release_id, artist_id)",
"CREATE INDEX idx_release_artist_artist ON furumusic__release_artist (artist_id)",
// track: search by normalized title
"CREATE INDEX idx_track_title_sort ON furumusic__track (title_sort)",
// track: FK to release
"CREATE INDEX idx_track_release ON furumusic__track (release_id)",
// track: FK to audio file
"CREATE INDEX idx_track_audio_file ON furumusic__track (audio_file_id)",
// track_artist: unique triple + lookups
"CREATE UNIQUE INDEX idx_track_artist_uniq ON furumusic__track_artist (track_id, artist_id, role)",
"CREATE INDEX idx_track_artist_artist ON furumusic__track_artist (artist_id)",
// track_genre: unique pair + lookup
"CREATE UNIQUE INDEX idx_track_genre_uniq ON furumusic__track_genre (track_id, genre_id)",
"CREATE INDEX idx_track_genre_genre ON furumusic__track_genre (genre_id)",
// genre: lookup by normalized name
"CREATE INDEX idx_genre_normalized ON furumusic__genre (name_normalized)",
// user_liked_track: unique pair + lookup by track
"CREATE UNIQUE INDEX idx_user_liked_track_uniq ON furumusic__user_liked_track (user_id, track_id)",
"CREATE INDEX idx_user_liked_track_track ON furumusic__user_liked_track (track_id)",
// user_followed_artist: unique pair + lookup by artist
"CREATE UNIQUE INDEX idx_user_followed_artist_uniq ON furumusic__user_followed_artist (user_id, artist_id)",
"CREATE INDEX idx_user_followed_artist_artist ON furumusic__user_followed_artist (artist_id)",
// playlist: owner lookup
"CREATE INDEX idx_playlist_owner ON furumusic__playlist (owner_id)",
// playlist_track: ordered tracks in playlist + lookup by track
"CREATE INDEX idx_playlist_track_playlist ON furumusic__playlist_track (playlist_id, position)",
"CREATE INDEX idx_playlist_track_track ON furumusic__playlist_track (track_id)",
// saved_playlist: unique pair + lookup by playlist
"CREATE UNIQUE INDEX idx_saved_playlist_uniq ON furumusic__saved_playlist (user_id, playlist_id)",
"CREATE INDEX idx_saved_playlist_playlist ON furumusic__saved_playlist (playlist_id)",
// play_history: user timeline + lookup by track
"CREATE INDEX idx_play_history_user ON furumusic__play_history (user_id, played_at)",
"CREATE INDEX idx_play_history_track ON furumusic__play_history (track_id)",
// playback_state: one per user
"CREATE UNIQUE INDEX idx_playback_state_user ON furumusic__playback_state (user_id)",
// processing_task: queue polling (status + created_at)
"CREATE INDEX idx_processing_task_status ON furumusic__processing_task (status, created_at)",
];
for stmt in stmts {
ctx.db.raw(stmt).await?;
}
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0019CreateMusicIndexes;
impl migrations::Migration for M0019CreateMusicIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0019_create_music_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0018_create_processing_task",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(create_music_indexes).build()];
}
// -- M0020: enable pg_trgm extension --------------------------------------
#[cot::db::migrations::migration_op]
async fn enable_pg_trgm(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db.raw("CREATE EXTENSION IF NOT EXISTS pg_trgm").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0020EnablePgTrgm;
impl migrations::Migration for M0020EnablePgTrgm {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0020_enable_pg_trgm";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0019_create_music_indexes",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(enable_pg_trgm).build()];
}
// -- M0021: GIN trigram indexes for fuzzy search --------------------------
#[cot::db::migrations::migration_op]
async fn create_trgm_indexes(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db.raw("CREATE INDEX idx_artist_name_sort_trgm ON furumusic__artist USING gin (name_sort gin_trgm_ops)").await?;
ctx.db.raw("CREATE INDEX idx_release_title_sort_trgm ON furumusic__release USING gin (title_sort gin_trgm_ops)").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0021CreateTrgmIndexes;
impl migrations::Migration for M0021CreateTrgmIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0021_create_trgm_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0020_enable_pg_trgm",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(create_trgm_indexes).build()];
}
// -- M0022: GIN trigram index on track.title_sort ---------------------------
#[cot::db::migrations::migration_op]
async fn create_track_trgm_index(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db.raw("CREATE INDEX IF NOT EXISTS idx_track_title_sort_trgm ON furumusic__track USING gin (title_sort gin_trgm_ops)").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0022CreateTrackTrgmIndex;
impl migrations::Migration for M0022CreateTrackTrgmIndex {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0022_create_track_trgm_index";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0021_create_trgm_indexes",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_track_trgm_index).build()];
}
// -- M0028: add model_name to artist, release, track -----------------------
#[cot::db::migrations::migration_op]
async fn add_model_name_columns(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE furumusic__artist ADD COLUMN model_name VARCHAR(128) DEFAULT NULL")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__release ADD COLUMN model_name VARCHAR(128) DEFAULT NULL")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__track ADD COLUMN model_name VARCHAR(128) DEFAULT NULL")
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0028AddModelNameColumns;
impl migrations::Migration for M0028AddModelNameColumns {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0028_add_model_name_columns";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0027_create_processing_stats",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_model_name_columns).build()];
}
// -- M0029: add volume column to playback_state ----------------------------
#[cot::db::migrations::migration_op]
async fn add_playback_volume(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE furumusic__playback_state ADD COLUMN volume DOUBLE PRECISION NOT NULL DEFAULT 0.7")
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0029AddPlaybackVolume;
impl migrations::Migration for M0029AddPlaybackVolume {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0029_add_playback_volume";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0028_add_model_name_columns",
)];
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()];
}
// -- M0030: add uploader attribution to media_file ------------------------
#[cot::db::migrations::migration_op]
async fn add_media_file_uploader(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploaded_by_user_id BIGINT DEFAULT NULL")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploader_name VARCHAR(255) NOT NULL DEFAULT 'UFO'")
.await?;
ctx.db
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploaded_by_user ON furumusic__media_file (uploaded_by_user_id)")
.await?;
ctx.db
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploader_name ON furumusic__media_file (uploader_name)")
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0030AddMediaFileUploader;
impl migrations::Migration for M0030AddMediaFileUploader {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0030_add_media_file_uploader";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0029_add_playback_volume",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_media_file_uploader).build()];
}
// -- M0031: persistent torrent import sessions ---------------------------
#[cot::db::migrations::migration_op]
async fn create_torrent_session(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__torrent_session (
id VARCHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
info_hash VARCHAR(80) NOT NULL,
source_kind VARCHAR(32) NOT NULL,
source_label TEXT,
torrent_bytes BYTEA NOT NULL,
files_json TEXT NOT NULL,
selected_files_json TEXT NOT NULL DEFAULT '[]',
status VARCHAR(32) NOT NULL,
total_size BIGINT NOT NULL DEFAULT 0,
selected_size BIGINT NOT NULL DEFAULT 0,
downloaded_bytes BIGINT NOT NULL DEFAULT 0,
uploaded_bytes BIGINT NOT NULL DEFAULT 0,
progress_percent DOUBLE PRECISION NOT NULL DEFAULT 0,
error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
completed_at VARCHAR(32)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_updated
ON furumusic__torrent_session (user_id, updated_at DESC)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_torrent_session_user_status
ON furumusic__torrent_session (user_id, status)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0031CreateTorrentSession;
impl migrations::Migration for M0031CreateTorrentSession {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0031_create_torrent_session";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0030_add_media_file_uploader",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_torrent_session).build()];
}
// -- M0032: Last.fm track popularity ------------------------------------
#[cot::db::migrations::migration_op]
async fn create_lastfm_track_popularity(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_listeners BIGINT")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_playcount BIGINT")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_rating DOUBLE PRECISION")
.await?;
ctx.db
.raw("ALTER TABLE furumusic__track ADD COLUMN lastfm_updated_at VARCHAR(32)")
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__track_popularity_history (
id BIGSERIAL PRIMARY KEY,
track_id BIGINT NOT NULL,
source VARCHAR(32) NOT NULL,
listeners BIGINT NOT NULL,
playcount BIGINT NOT NULL,
rating DOUBLE PRECISION NOT NULL,
fetched_at VARCHAR(32) NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_track_popularity_history_track
ON furumusic__track_popularity_history (track_id, fetched_at DESC)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0032CreateLastfmTrackPopularity;
impl migrations::Migration for M0032CreateLastfmTrackPopularity {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0032_create_lastfm_track_popularity";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0031_create_torrent_session",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_lastfm_track_popularity).build()];
}
// -- M0033: Last.fm scrobbling -----------------------------------------
#[cot::db::migrations::migration_op]
async fn create_lastfm_scrobbling(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_account (
user_id BIGINT PRIMARY KEY,
lastfm_username VARCHAR(255) NOT NULL,
session_key TEXT NOT NULL,
connected_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
last_error TEXT,
reauth_required BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_auth_state (
state VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at VARCHAR(32) NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__lastfm_scrobble_outbox (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
track_id BIGINT NOT NULL,
started_at BIGINT NOT NULL,
listened_seconds INTEGER NOT NULL,
duration_seconds INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
scrobbled_at VARCHAR(32),
dedupe_key VARCHAR(128) NOT NULL UNIQUE
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_status
ON furumusic__lastfm_scrobble_outbox (status, created_at, id)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_lastfm_scrobble_outbox_user
ON furumusic__lastfm_scrobble_outbox (user_id, status, created_at)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0033CreateLastfmScrobbling;
impl migrations::Migration for M0033CreateLastfmScrobbling {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0033_create_lastfm_scrobbling";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0032_create_lastfm_track_popularity",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_lastfm_scrobbling).build()];
}
// -- M0034: Artwork lookup state --------------------------------------
#[cot::db::migrations::migration_op]
async fn create_artwork_lookup_state(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__artwork_lookup_state (
id BIGSERIAL PRIMARY KEY,
entity_kind VARCHAR(32) NOT NULL,
entity_id BIGINT NOT NULL,
source VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
last_attempt_at VARCHAR(32) NOT NULL,
last_error TEXT,
source_url TEXT,
UNIQUE(entity_kind, entity_id, source)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_artwork_lookup_state_retry
ON furumusic__artwork_lookup_state (entity_kind, source, status, last_attempt_at)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0034CreateArtworkLookupState;
impl migrations::Migration for M0034CreateArtworkLookupState {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0034_create_artwork_lookup_state";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0033_create_lastfm_scrobbling",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_artwork_lookup_state).build()];
}
// -- M0035: Weighted metadata tags for tracks, releases, and artists ----
#[cot::db::migrations::migration_op]
async fn create_entity_genre_tags(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__entity_genre_tag (
id BIGSERIAL PRIMARY KEY,
entity_kind VARCHAR(32) NOT NULL,
entity_id BIGINT NOT NULL,
genre_id BIGINT NOT NULL,
source VARCHAR(32) NOT NULL,
weight DOUBLE PRECISION NOT NULL DEFAULT 1,
updated_at VARCHAR(32) NOT NULL,
UNIQUE(entity_kind, entity_id, genre_id, source)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_entity
ON furumusic__entity_genre_tag (entity_kind, entity_id, source)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_genre
ON furumusic__entity_genre_tag (genre_id, entity_kind)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0035CreateEntityGenreTags;
impl migrations::Migration for M0035CreateEntityGenreTags {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0035_create_entity_genre_tags";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0034_create_artwork_lookup_state",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_entity_genre_tags).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
&M0008CreateRelease,
&M0009CreateReleaseArtist,
&M0010CreateTrack,
&M0011CreateTrackArtist,
&M0012CreateGenreTables,
&M0013CreateUserLikedTrack,
&M0014CreateUserFollowedArtist,
&M0015CreatePlaylistTables,
&M0016CreatePlayHistory,
&M0017CreatePlaybackState,
&M0018CreateProcessingTask,
&M0019CreateMusicIndexes,
&M0020EnablePgTrgm,
&M0021CreateTrgmIndexes,
&M0022CreateTrackTrgmIndex,
&M0028AddModelNameColumns,
&M0029AddPlaybackVolume,
&M0030AddMediaFileUploader,
&M0031CreateTorrentSession,
&M0032CreateLastfmTrackPopularity,
&M0033CreateLastfmScrobbling,
&M0034CreateArtworkLookupState,
&M0035CreateEntityGenreTags,
];
}