Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 124 additions & 1 deletion crates/apotheke/src/repo/music.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,24 +563,39 @@ pub async fn search_tracks(
pool: &SqlitePool,
query: &str,
limit: i64,
offset: i64,
) -> Result<Vec<MusicTrack>, DbError> {
let pattern = format!("%{query}%");
// WHY: deterministic ORDER BY — OFFSET pagination over an unordered
// result set yields overlapping/unstable pages.
sqlx::query_as::<_, MusicTrack>(
"SELECT id, medium_id, position, title, duration_ms, mb_recording_id,
acoustid_fingerprint, acoustid_id, file_path, file_size_bytes,
bit_depth, sample_rate, codec, quality_score,
replay_gain_track_db, replay_gain_album_db, source_type, added_at
FROM music_tracks WHERE title LIKE ? LIMIT ?",
FROM music_tracks WHERE title LIKE ? ORDER BY title, id LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await
.context(QuerySnafu {
table: "music_tracks",
})
}

pub async fn count_tracks(pool: &SqlitePool, query: &str) -> Result<i64, DbError> {
let pattern = format!("%{query}%");
sqlx::query_scalar("SELECT COUNT(*) FROM music_tracks WHERE title LIKE ?")
.bind(&pattern)
.fetch_one(pool)
.await
.context(QuerySnafu {
table: "music_tracks",
})
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -882,4 +897,112 @@ mod tests {
.unwrap();
assert!(meta.is_none());
}

async fn seed_tracks(pool: &SqlitePool, count: usize) -> Vec<Vec<u8>> {
let group_id = make_id();
let group = MusicReleaseGroup {
id: group_id.clone(),
registry_id: None,
title: "Pagination Album".to_string(),
rg_type: "album".to_string(),
mb_release_group_id: None,
year: Some(2024),
quality_profile_id: None,
added_at: now(),
};
insert_release_group(pool, &group).await.unwrap();

let release_id = make_id();
let release = MusicRelease {
id: release_id.clone(),
release_group_id: group_id,
title: "Pagination Album".to_string(),
release_date: None,
country: None,
label: None,
catalog_number: None,
mb_release_id: None,
added_at: now(),
};
insert_release(pool, &release).await.unwrap();

let medium_id = make_id();
let medium = MusicMedium {
id: medium_id.clone(),
release_id,
position: 1,
format: "Digital".to_string(),
title: None,
};
insert_medium(pool, &medium).await.unwrap();

let mut ids = Vec::with_capacity(count);
for i in 0..count {
let track_id = make_id();
let track = MusicTrack {
id: track_id.clone(),
medium_id: medium_id.clone(),
position: i as i64,
title: format!("Track {i:02}"),
duration_ms: Some(180000),
mb_recording_id: None,
acoustid_fingerprint: None,
acoustid_id: None,
file_path: None,
file_size_bytes: None,
bit_depth: None,
sample_rate: None,
codec: None,
quality_score: None,
replay_gain_track_db: None,
replay_gain_album_db: None,
source_type: "local".to_string(),
added_at: now(),
};
insert_track(pool, &track).await.unwrap();
ids.push(track_id);
}
ids
}

#[tokio::test]
async fn search_tracks_paginates_with_offset() {
let pool = setup().await;
seed_tracks(&pool, 7).await;

let page1 = search_tracks(&pool, "", 5, 0).await.unwrap();
let page2 = search_tracks(&pool, "", 5, 5).await.unwrap();
assert_eq!(page1.len(), 5);
assert_eq!(page2.len(), 2);

let ids1: std::collections::HashSet<Vec<u8>> = page1.into_iter().map(|t| t.id).collect();
let ids2: std::collections::HashSet<Vec<u8>> = page2.into_iter().map(|t| t.id).collect();
assert!(ids1.is_disjoint(&ids2), "pages must not overlap");

let past_end = search_tracks(&pool, "", 5, 10).await.unwrap();
assert!(past_end.is_empty());
}

#[tokio::test]
async fn search_tracks_filters_by_query_with_offset() {
let pool = setup().await;
seed_tracks(&pool, 3).await;

let hits = search_tracks(&pool, "Track 01", 10, 0).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].title, "Track 01");

let beyond = search_tracks(&pool, "Track 01", 10, 1).await.unwrap();
assert!(beyond.is_empty());
}

#[tokio::test]
async fn count_tracks_returns_full_match_count() {
let pool = setup().await;
seed_tracks(&pool, 7).await;

assert_eq!(count_tracks(&pool, "").await.unwrap(), 7);
assert_eq!(count_tracks(&pool, "Track 03").await.unwrap(), 1);
assert_eq!(count_tracks(&pool, "No Such Title").await.unwrap(), 0);
}
}
37 changes: 37 additions & 0 deletions crates/apotheke/src/repo/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,15 @@ pub async fn revoke_api_key(pool: &SqlitePool, id: &[u8]) -> Result<(), DbError>
Ok(())
}

pub async fn revoke_api_keys_for_user(pool: &SqlitePool, user_id: &[u8]) -> Result<(), DbError> {
sqlx::query("UPDATE api_keys SET revoked = 1 WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await
.context(QuerySnafu { table: "api_keys" })?;
Ok(())
}

pub async fn update_api_key_last_used(
pool: &SqlitePool,
id: &[u8],
Expand Down Expand Up @@ -498,4 +507,32 @@ mod tests {
let results = list_users(&pool, 10, 0).await.unwrap();
assert!(results.is_empty());
}

#[tokio::test]
async fn revoke_api_keys_for_user_revokes_all() {
let pool = setup().await;
let user_id = make_id();
let user = test_user(user_id.clone());
insert_user(&pool, &user).await.unwrap();

for (short, long) in [("key00001", "hash1"), ("key00002", "hash2")] {
let key = ApiKey {
id: make_id(),
user_id: user_id.clone(),
short_token: short.to_string(),
long_token_hash: long.to_string(),
label: "test".to_string(),
created_at: now(),
last_used_at: None,
revoked: 0,
};
insert_api_key(&pool, &key).await.unwrap();
}

revoke_api_keys_for_user(&pool, &user_id).await.unwrap();

let keys = list_api_keys_for_user(&pool, &user_id).await.unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| k.revoked == 1));
}
}
2 changes: 2 additions & 0 deletions crates/paroche/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ mdns-sd.workspace = true
jiff.workspace = true
ulid.workspace = true
sha1.workspace = true
subtle.workspace = true
url.workspace = true

[dev-dependencies]
sqlx.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/paroche/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod discovery;
pub mod error;
pub mod middleware;
pub mod net_validate;
pub mod opds;
pub mod response;
pub mod routes;
Expand Down
Loading