Compare commits

...

6 Commits

Author SHA1 Message Date
Ultradesu 4b8797bb2e Added lastfm statistics
Build and Publish / Build and Publish Docker Image (push) Successful in 2m58s
2026-05-26 18:16:34 +03:00
Ultradesu d425bf3087 Improved upload UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m2s
2026-05-26 16:59:36 +03:00
Ultradesu 82923c871e Improved torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m45s
2026-05-26 16:21:21 +03:00
Ultradesu 3878d746d2 Improved torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m2s
2026-05-26 14:49:56 +03:00
Ultradesu 31ae57a5a3 Improved torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m50s
2026-05-26 14:47:10 +03:00
ab 16de1fb711 Reworked torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 4m51s
2026-05-26 12:55:11 +03:00
22 changed files with 8545 additions and 5273 deletions
Generated
+1 -1
View File
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.1.10"
version = "0.1.14"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.1.10"
version = "0.1.15"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+14
View File
@@ -264,6 +264,20 @@ impl App for AdminApp {
}),
"admin_v2_job_run",
),
Route::with_handler_and_name(
"/v2/api/settings",
get(move |session: Session, db: Database| async move {
v2::settings(session, db).await
})
.post(
move |session: Session,
db: Database,
json: Json<v2::UpdateSettingsRequest>| async move {
v2::update_settings(session, db, json).await
},
),
"admin_v2_settings",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/toggle",
cot::router::method::post({
+44 -1
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use cot::db::Database;
use cot::db::{Database, Model};
use cot::html::Html;
use cot::http::StatusCode;
use cot::http::header::CONTENT_TYPE;
@@ -14,6 +14,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder};
use super::BUILD_INFO;
use crate::auth::{self, AuthenticatedUser, Role};
use crate::config::{AppConfig, ConfigEntry};
use crate::i18n::{I18n, Translations};
use crate::scheduler::{JobRegistry, ScheduledJob};
@@ -214,6 +215,17 @@ struct MutationResponse {
affected: u64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminSettingsDto {
lastfm_api_key: String,
lastfm_api_key_configured: bool,
}
#[derive(Debug, Deserialize)]
pub(super) struct UpdateSettingsRequest {
lastfm_api_key: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LibraryOverviewDto {
artists: i64,
@@ -458,6 +470,37 @@ pub async fn jobs(
Json(jobs).into_response()
}
pub async fn settings(session: Session, db: Database) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let (config, _) = AppConfig::load_with_db(&db).await;
Json(AdminSettingsDto {
lastfm_api_key_configured: !config.lastfm_api_key.trim().is_empty(),
lastfm_api_key: config.lastfm_api_key,
})
.into_response()
}
pub async fn update_settings(
session: Session,
db: Database,
Json(body): Json<UpdateSettingsRequest>,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let mut entry = ConfigEntry::new(
"lastfm_api_key".to_string(),
body.lastfm_api_key.trim().to_string(),
);
entry
.save(&db)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(serde_json::json!({ "ok": true })).into_response()
}
pub async fn run_job(
session: Session,
db: Database,
+70
View File
@@ -133,6 +133,7 @@ pub struct ConfigSources {
pub agent_confidence_threshold: ConfigSource,
pub agent_context_limit: ConfigSource,
pub agent_concurrency: ConfigSource,
pub lastfm_api_key: ConfigSource,
}
impl Default for ConfigSources {
@@ -158,6 +159,7 @@ impl Default for ConfigSources {
agent_confidence_threshold: ConfigSource::Default,
agent_context_limit: ConfigSource::Default,
agent_concurrency: ConfigSource::Default,
lastfm_api_key: ConfigSource::Default,
}
}
}
@@ -262,6 +264,8 @@ pub struct AppConfig {
pub agent_context_limit: u64,
/// Number of files to process in parallel via the LLM.
pub agent_concurrency: u64,
/// Last.fm API key for weekly popularity enrichment.
pub lastfm_api_key: String,
}
impl Default for AppConfig {
@@ -287,6 +291,7 @@ impl Default for AppConfig {
agent_confidence_threshold: 0.85,
agent_context_limit: 8192,
agent_concurrency: 2,
lastfm_api_key: String::new(),
}
}
}
@@ -313,14 +318,21 @@ impl_env_overrides!(
agent_confidence_threshold,
agent_context_limit,
agent_concurrency,
lastfm_api_key,
);
impl AppConfig {
fn normalize_host_paths(&mut self) {
self.agent_inbox_dir = normalize_host_path(&self.agent_inbox_dir);
self.agent_storage_dir = normalize_host_path(&self.agent_storage_dir);
}
/// Build config: start from defaults, then overlay env vars.
/// Used at startup before the DB is available (to get `database_url`).
pub fn load() -> Self {
let mut cfg = Self::default();
cfg.apply_env_overrides();
cfg.normalize_host_paths();
cfg
}
@@ -331,6 +343,7 @@ impl AppConfig {
let mut sources = ConfigSources::default();
cfg.apply_db_overrides(db, &mut sources).await;
cfg.apply_env_overrides_tracked(&mut sources);
cfg.normalize_host_paths();
(cfg, sources)
}
@@ -389,9 +402,48 @@ impl AppConfig {
apply_db_field!(agent_confidence_threshold);
apply_db_field!(agent_context_limit);
apply_db_field!(agent_concurrency);
apply_db_field!(lastfm_api_key);
}
}
fn normalize_host_path(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return String::new();
}
normalize_windows_user_path(trimmed).unwrap_or_else(|| trimmed.to_owned())
}
#[cfg(not(windows))]
fn normalize_windows_user_path(value: &str) -> Option<String> {
let normalized = value.replace('\\', "/");
let mut parts = normalized.split('/').filter(|part| !part.is_empty());
let drive = parts.next()?;
if drive.len() != 2 || !drive.ends_with(':') {
return None;
}
if !parts.next()?.eq_ignore_ascii_case("Users") {
return None;
}
let user = parts.next()?;
if user.is_empty() {
return None;
}
let mut out = format!("/Users/{user}");
for part in parts {
out.push('/');
out.push_str(part);
}
Some(out)
}
#[cfg(windows)]
fn normalize_windows_user_path(_value: &str) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -403,6 +455,24 @@ mod tests {
assert_eq!(cfg.log_level, "info");
}
#[cfg(not(windows))]
#[test]
fn normalizes_windows_user_path_on_unix() {
assert_eq!(
normalize_host_path(r"C:\Users\ab\repos\furumusic\media\uploads"),
"/Users/ab/repos/furumusic/media/uploads"
);
}
#[cfg(not(windows))]
#[test]
fn leaves_unix_path_unchanged() {
assert_eq!(
normalize_host_path("/Users/ab/repos/furumusic/media/uploads"),
"/Users/ab/repos/furumusic/media/uploads"
);
}
// SAFETY: tests run with --test-threads=1 so no concurrent env access.
unsafe fn set(k: &str, v: &str) {
unsafe { std::env::set_var(k, v) };
+156
View File
@@ -264,4 +264,160 @@ translations! {
settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ";
settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек";
settings_agent_status_loading: "Checking connection" , "Проверка подключения";
// Player UI
player_library: "Library" , "Библиотека";
player_artists: "Artists" , "Артисты";
player_releases: "Releases" , "Релизы";
player_tracks: "Tracks" , "Треки";
player_title: "Title" , "Название";
player_duration: "Duration" , "Длительность";
player_following: "Following" , "Подписки";
player_follow: "Follow" , "Подписаться";
player_followed: "Following" , "Вы подписаны";
player_unfollow_artist: "Unfollow artist" , "Отписаться от артиста";
player_follow_artist: "Follow artist" , "Подписаться на артиста";
player_no_followed_artists: "No followed artists" , "Нет подписок на артистов";
player_playlists: "Playlists" , "Плейлисты";
player_published_playlists: "Published Playlists" , "Опубликованные плейлисты";
player_public: "Public" , "Публичный";
player_published: "Published" , "Опубликован";
player_by: "by" , "от";
player_tracks_count: "tracks" , "треков";
player_files_count: "files" , "файлов";
player_releases_count: "releases" , "релизов";
player_plays_count: "plays" , "прослушиваний";
player_likes_count: "likes" , "лайков";
player_likes_playlist: "Likes" , "Лайки";
player_listened: "listened" , "прослушано";
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_no_results: "No results found" , "Ничего не найдено";
player_new_playlist: "New Playlist" , "Новый плейлист";
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
player_playlist_name: "Playlist name" , "Название плейлиста";
player_add_to_playlist: "Add to Playlist" , "Добавить в плейлист";
player_cancel: "Cancel" , "Отмена";
player_create: "Create" , "Создать";
player_save: "Save" , "Сохранить";
player_delete: "Delete" , "Удалить";
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
player_rename: "Rename" , "Переименовать";
player_close: "Close" , "Закрыть";
player_log_out: "Log out" , "Выйти";
player_admin_panel: "Admin Panel" , "Админка";
player_info: "Info" , "Информация";
player_no_details: "No details available." , "Нет подробностей.";
player_release_info: "Release info" , "Информация о релизе";
player_track_info: "Track info" , "Информация о треке";
player_type: "Type" , "Тип";
player_year: "Year" , "Год";
player_uploaders: "Uploaders" , "Загрузили";
player_unknown: "unknown" , "неизвестно";
player_unknown_size: "unknown size" , "размер неизвестен";
player_unknown_release: "Unknown release" , "Неизвестный релиз";
player_unknown_track: "Unknown track" , "Неизвестный трек";
player_unknown_audio: "unknown audio details" , "детали аудио неизвестны";
player_release_year: "Release year" , "Год релиза";
player_audio: "Audio" , "Аудио";
player_size: "Size" , "Размер";
player_uploader: "Uploader" , "Загрузил";
player_lastfm_rating: "Last.fm popularity" , "Популярность Last.fm";
player_lastfm_listeners: "Last.fm listeners" , "Слушатели Last.fm";
player_lastfm_playcount: "Last.fm plays" , "Прослушивания Last.fm";
player_lastfm_updated: "Last.fm updated" , "Last.fm обновлён";
player_lastfm_not_loaded: "not loaded yet" , "ещё не загружено";
player_play: "Play" , "Играть";
player_like: "Like" , "Лайк";
player_add_to_queue: "Add to queue" , "Добавить в очередь";
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
player_play_next: "Play next" , "Играть следующим";
player_queue: "Queue" , "Очередь";
player_next: "Next" , "Далее";
player_previous: "Previous" , "Назад";
player_clear: "Clear" , "Очистить";
player_remove: "Remove" , "Удалить";
player_queue_empty: "Queue is empty" , "Очередь пуста";
player_shuffle: "Shuffle" , "Перемешать";
player_repeat: "Repeat" , "Повтор";
player_volume: "Volume" , "Громкость";
player_appears_on: "Appears on" , "Участвует в";
player_albums: "Albums" , "Альбомы";
player_eps: "EPs" , "EP";
player_singles: "Singles" , "Синглы";
player_compilations: "Compilations" , "Сборники";
player_mixtapes: "Mixtapes" , "Микстейпы";
player_live_releases: "Live releases" , "Концертные релизы";
player_soundtracks: "Soundtracks" , "Саундтреки";
// Player torrent/history UI
player_torrent_manager: "Torrent manager" , "Торрент-менеджер";
player_import_torrent: "Import torrent" , "Импортировать торрент";
player_client_idle: "Client idle" , "Клиент простаивает";
player_active: "active" , "активно";
player_ai_idle: "AI idle" , "ИИ простаивает";
player_ai_prefix: "AI" , "ИИ";
player_processing: "processing" , "обрабатывается";
player_queued: "queued" , "в очереди";
player_saved: "saved" , "сохранено";
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
player_refresh: "Refresh" , "Обновить";
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
player_upload: "Upload" , "Загрузить";
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
player_local_files: "Local audio files" , "Локальные аудиофайлы";
player_torrent_file: "Torrent file" , "Torrent-файл";
player_magnet_link: "Magnet link" , "Magnet-ссылка";
player_upload_content: "Upload" , "Загрузить";
player_download_selected: "Download selected" , "Скачать выбранное";
player_pause_download: "Pause download" , "Поставить на паузу";
player_expand_all: "Expand all" , "Развернуть всё";
player_collapse: "Collapse" , "Свернуть";
player_selected: "selected" , "выбрано";
player_preview: "Preview" , "Предпросмотр";
player_resolving: "Resolving metadata" , "Получаю метаданные";
player_downloading: "Downloading" , "Скачивается";
player_moving: "Moving" , "Перемещается";
player_completed: "Completed" , "Готово";
player_failed: "Failed" , "Ошибка";
player_paused: "Paused" , "Пауза";
player_no_torrent_selected: "No torrent selected" , "Торрент не выбран";
player_downloaded: "Downloaded" , "Загружено";
player_speed: "Speed" , "Скорость";
player_down: "down" , "вниз";
player_up: "up" , "вверх";
player_peers: "peers" , "пиры";
player_live: "live" , "активных";
player_seen: "seen" , "видели";
player_eta: "eta" , "осталось";
player_loading_history: "Loading history..." , "Загрузка истории...";
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
player_total_plays: "total plays" , "прослушиваний всего";
player_play_history: "Play history" , "История прослушиваний";
player_no_plays_yet: "No plays yet" , "Прослушиваний пока нет";
player_page: "Page" , "Страница";
player_of: "of" , "из";
player_choose_torrent: "Choose local files, paste a magnet link, or choose a .torrent file." , "Выберите локальные файлы, вставьте magnet-ссылку или выберите .torrent файл.";
player_uploading_files: "Uploading files..." , "Загружаю файлы...";
player_upload_complete: "Upload complete. Files are queued for processing." , "Загрузка завершена. Файлы поставлены в обработку.";
player_upload_failed: "Upload failed" , "Загрузка не удалась";
player_reading_torrent: "Reading torrent file..." , "Читаю torrent-файл...";
player_resolving_magnet: "Resolving magnet metadata. This can take a while..." , "Получаю метаданные magnet-ссылки. Это может занять время...";
player_preview_failed: "Preview failed" , "Предпросмотр не удался";
player_all_files_selected: "All files are selected by default. Clear or adjust the tree before download." , "Все файлы выбраны по умолчанию. Перед скачиванием можно очистить или изменить выбор.";
player_opening_saved_torrent: "Opening saved torrent..." , "Открываю сохранённый торрент...";
player_saved_torrent_opened: "Saved torrent opened. Adjust files or resume download." , "Сохранённый торрент открыт. Можно изменить файлы или продолжить скачивание.";
player_remove_torrent_confirm: "Remove this torrent from the client list? Downloaded files will stay on disk." , "Удалить этот торрент из списка клиента? Скачанные файлы останутся на диске.";
player_torrent_removed: "Torrent removed from the client list." , "Торрент удалён из списка клиента.";
player_select_one_file: "Select at least one file." , "Выберите хотя бы один файл.";
player_starting_download: "Starting download..." , "Запускаю скачивание...";
player_download_started: "Download started. Files will move to inbox when complete." , "Скачивание началось. После завершения файлы будут перенесены во входящие.";
player_pausing_download: "Pausing download..." , "Ставлю скачивание на паузу...";
player_download_paused: "Download paused. Start again when you are ready." , "Скачивание на паузе. Можно продолжить позже.";
player_status_failed: "Status failed" , "Не удалось получить статус";
player_start_failed: "Start failed" , "Не удалось запустить";
player_pause_failed: "Pause failed" , "Не удалось поставить на паузу";
player_load_torrents_failed: "Could not load torrents" , "Не удалось загрузить торренты";
player_open_torrent_failed: "Could not open torrent" , "Не удалось открыть торрент";
player_delete_torrent_failed: "Could not delete torrent" , "Не удалось удалить торрент";
player_load_ai_queue_failed: "Could not load AI queue" , "Не удалось загрузить очередь ИИ";
}
+249
View File
@@ -0,0 +1,249 @@
use serde::Deserialize;
use crate::scheduler::{Job, JobContext, JobLog};
pub struct LastfmPopularityJob;
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
#[derive(Debug, sqlx::FromRow)]
struct TrackLookupRow {
id: i64,
title: String,
artist_name: Option<String>,
lastfm_updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LastfmTrackInfoResponse {
track: Option<LastfmTrack>,
error: Option<i32>,
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LastfmTrack {
listeners: Option<String>,
playcount: Option<String>,
}
#[async_trait::async_trait]
impl Job for LastfmPopularityJob {
fn name(&self) -> &'static str {
"lastfm_popularity"
}
fn description(&self) -> &'static str {
"Update Last.fm playcount/listener popularity for library tracks"
}
fn default_cron(&self) -> &'static str {
// Sundays at 04:15
"0 15 4 * * Sun"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let api_key = ctx.config.lastfm_api_key.trim();
if api_key.is_empty() {
log.warn("lastfm_api_key is not configured, skipping Last.fm popularity update");
return Ok(());
}
let tracks = sqlx::query_as::<_, TrackLookupRow>(
r#"SELECT t.id,
t.title::text AS title,
t.lastfm_updated_at::text AS lastfm_updated_at,
(
SELECT a.name::text
FROM furumusic__track_artist ta
JOIN furumusic__artist a ON a.id = ta.artist_id
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
ORDER BY ta.position
LIMIT 1
) AS artist_name
FROM furumusic__track t
WHERE t.is_hidden = false
ORDER BY t.lastfm_updated_at IS NOT NULL, t.lastfm_updated_at ASC, t.id ASC"#,
)
.fetch_all(&ctx.pool)
.await?;
if tracks.is_empty() {
log.info("No visible tracks found for Last.fm popularity update");
return Ok(());
}
log.info(&format!(
"Starting Last.fm popularity update for {} visible tracks; oldest or missing ratings are processed first; request delay is {} ms; rating formula is ln(playcount + 1) * ln(listeners + 1)",
tracks.len(),
LASTFM_REQUEST_DELAY.as_millis()
));
let client = reqwest::Client::builder()
.user_agent("furumusic-lastfm-popularity/0.1")
.timeout(std::time::Duration::from_secs(15))
.build()?;
let mut updated = 0u64;
let mut skipped = 0u64;
let mut failed = 0u64;
for (index, track) in tracks.iter().enumerate() {
let Some(artist) = track
.artist_name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
else {
skipped += 1;
log.warn(&format!(
"Skipping track {} \"{}\": no primary artist",
track.id, track.title
));
continue;
};
log.info(&format!(
"Last.fm lookup {}/{}: track {} \"{}\" by \"{}\" (previous update: {})",
index + 1,
tracks.len(),
track.id,
track.title,
artist,
track.lastfm_updated_at.as_deref().unwrap_or("never")
));
let result = fetch_track_info(&client, api_key, artist, &track.title).await;
match result {
Ok(Some((listeners, playcount))) => {
let rating = popularity_rating(listeners, playcount);
let fetched_at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
sqlx::query(
r#"UPDATE furumusic__track
SET lastfm_listeners = $2,
lastfm_playcount = $3,
lastfm_rating = $4,
lastfm_updated_at = $5
WHERE id = $1"#,
)
.bind(track.id)
.bind(listeners)
.bind(playcount)
.bind(rating)
.bind(&fetched_at)
.execute(&ctx.pool)
.await?;
sqlx::query(
r#"INSERT INTO furumusic__track_popularity_history
(track_id, source, listeners, playcount, rating, fetched_at)
VALUES ($1, 'lastfm', $2, $3, $4, $5)"#,
)
.bind(track.id)
.bind(listeners)
.bind(playcount)
.bind(rating)
.bind(&fetched_at)
.execute(&ctx.pool)
.await?;
updated += 1;
log.info(&format!(
"Updated track {} \"{}\" by \"{}\": listeners={listeners}, playcount={playcount}, rating={rating:.4}",
track.id, track.title, artist
));
}
Ok(None) => {
skipped += 1;
log.warn(&format!(
"Last.fm has no usable match for track {} \"{}\" by \"{}\"",
track.id, track.title, artist
));
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
failed += 1;
log.error("Last.fm rate limit exceeded; stopping this run early");
break;
}
Err(err) => {
failed += 1;
log.warn(&format!(
"Last.fm lookup failed for track {} \"{}\" / \"{}\": {err}",
track.id, artist, track.title
));
}
}
if (index + 1) % 50 == 0 {
log.info(&format!(
"Last.fm progress: {}/{} tracks, {updated} updated, {skipped} skipped, {failed} failed",
index + 1,
tracks.len()
));
}
tokio::time::sleep(LASTFM_REQUEST_DELAY).await;
}
log.info(&format!(
"Last.fm popularity update finished: {updated} updated, {skipped} skipped, {failed} failed, {} considered",
tracks.len()
));
Ok(())
}
}
async fn fetch_track_info(
client: &reqwest::Client,
api_key: &str,
artist: &str,
track: &str,
) -> anyhow::Result<Option<(i64, i64)>> {
let response = client
.get("https://ws.audioscrobbler.com/2.0/")
.query(&[
("method", "track.getInfo"),
("api_key", api_key),
("artist", artist),
("track", track),
("autocorrect", "1"),
("format", "json"),
])
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response.error_for_status()?;
let body: LastfmTrackInfoResponse = response.json().await?;
if let Some(code) = body.error {
if code == 29 {
anyhow::bail!("Last.fm rate limit exceeded");
}
if code == 6 || code == 7 {
return Ok(None);
}
anyhow::bail!(
"Last.fm API error {code}: {}",
body.message.unwrap_or_else(|| "unknown error".to_string())
);
}
let Some(info) = body.track else {
return Ok(None);
};
let listeners = info
.listeners
.as_deref()
.unwrap_or("0")
.parse::<i64>()
.unwrap_or(0);
let playcount = info
.playcount
.as_deref()
.unwrap_or("0")
.parse::<i64>()
.unwrap_or(0);
Ok(Some((listeners.max(0), playcount.max(0))))
}
fn popularity_rating(listeners: i64, playcount: i64) -> f64 {
let listeners = listeners.max(0) as f64;
let playcount = playcount.max(0) as f64;
playcount.ln_1p() * listeners.ln_1p()
}
+1
View File
@@ -3,6 +3,7 @@ pub mod artist_track_image_backfill;
pub mod cover_backfill;
pub mod inbox_discover;
pub mod inbox_process;
pub mod lastfm_popularity;
pub mod metadata_backfill;
use std::path::{Component, Path, PathBuf};
+1
View File
@@ -53,6 +53,7 @@ fn build_registry() -> Arc<JobRegistry> {
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
Arc::new(registry)
}
+116
View File
@@ -1578,6 +1578,120 @@ pub mod db_migrations {
&[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()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1599,5 +1713,7 @@ pub mod db_migrations {
&M0028AddModelNameColumns,
&M0029AddPlaybackVolume,
&M0030AddMediaFileUploader,
&M0031CreateTorrentSession,
&M0032CreateLastfmTrackPopularity,
];
}
+1 -5
View File
@@ -389,11 +389,7 @@ pub async fn oidc_callback_handler(
config.oidc_user_groups,
);
if !is_allowed_by_groups(
&groups,
&config.oidc_user_groups,
&config.oidc_admin_groups,
) {
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
tracing::warn!(
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups,
+14
View File
@@ -64,6 +64,10 @@ pub(super) struct TrackItem {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -84,6 +88,10 @@ pub(super) struct ArtistAppearanceTrack {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -162,6 +170,12 @@ pub(super) struct UserProfile {
pub(super) stats: UserStats,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct AgentQueueStatus {
pub(super) queued_count: i64,
pub(super) processing_count: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayHistoryItem {
pub(super) id: i64,
+502 -19
View File
@@ -2,7 +2,9 @@ use std::sync::Arc;
use cot::db::Database;
use cot::http::StatusCode;
use cot::http::header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, RANGE};
use cot::http::header::{
ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HeaderName, RANGE,
};
use cot::json::Json;
use cot::request::extractors::Path;
use cot::response::IntoResponse;
@@ -40,6 +42,13 @@ fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
.expect("valid response")
}
#[derive(serde::Serialize)]
struct LocalUploadResponse {
ok: bool,
filename: String,
size: u64,
}
// ---------------------------------------------------------------------------
// SPA shell
// ---------------------------------------------------------------------------
@@ -105,6 +114,36 @@ async fn me_handler(
.into_response()
}
// ---------------------------------------------------------------------------
// GET /api/player/agent-queue
// ---------------------------------------------------------------------------
async fn agent_queue_handler(
session: Session,
db: Database,
pool: &sqlx::PgPool,
) -> cot::Result<cot::response::Response> {
let Some(_user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let (queued_count, processing_count): (i64, i64) = sqlx::query_as(
r#"SELECT
COUNT(*) FILTER (WHERE status = 'queued') AS queued_count,
COUNT(*) FILTER (WHERE status = 'processing') AS processing_count
FROM furumusic__pending_review"#,
)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(AgentQueueStatus {
queued_count,
processing_count,
})
.into_response()
}
// ---------------------------------------------------------------------------
// GET /api/player/artists?page=N&limit=N
// ---------------------------------------------------------------------------
@@ -274,7 +313,11 @@ async fn artist_detail_handler(
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_id
@@ -351,6 +394,10 @@ async fn artist_detail_handler(
audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes,
lastfm_listeners: t.lastfm_listeners,
lastfm_playcount: t.lastfm_playcount,
lastfm_rating: t.lastfm_rating,
lastfm_updated_at: t.lastfm_updated_at,
}
})
.collect();
@@ -420,7 +467,11 @@ async fn release_detail_handler(
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
@@ -496,6 +547,10 @@ async fn release_detail_handler(
audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes,
lastfm_listeners: t.lastfm_listeners,
lastfm_playcount: t.lastfm_playcount,
lastfm_rating: t.lastfm_rating,
lastfm_updated_at: t.lastfm_updated_at,
}
})
.collect();
@@ -650,7 +705,11 @@ async fn playlist_detail_handler(
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__playlist_track pt
JOIN furumusic__track t ON t.id = pt.track_id
JOIN furumusic__release r ON r.id = t.release_id
@@ -746,6 +805,10 @@ async fn build_track_items(
audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes,
lastfm_listeners: t.lastfm_listeners,
lastfm_playcount: t.lastfm_playcount,
lastfm_rating: t.lastfm_rating,
lastfm_updated_at: t.lastfm_updated_at,
}
})
.collect())
@@ -880,6 +943,140 @@ async fn stream_handler(
Ok(response)
}
async fn local_upload_handler(
session: Session,
db: Database,
config: AppConfig,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
request: cot::request::Request,
) -> cot::Result<cot::http::Response<Body>> {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let inbox_dir = config.agent_inbox_dir.trim();
if inbox_dir.is_empty() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"agent_inbox_dir is not configured",
));
}
let inbox_root = std::path::PathBuf::from(inbox_dir);
if !inbox_root.is_absolute() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"agent_inbox_dir must be an absolute path",
));
}
let filename_header = HeaderName::from_static("x-furumusic-filename");
let original_name = request
.headers()
.get(filename_header)
.and_then(|value| value.to_str().ok())
.map(percent_decode_header)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "upload.mp3".to_string());
let filename = sanitize_upload_filename(&original_name);
let bytes = request
.into_body()
.into_bytes()
.await
.map_err(|err| cot::Error::internal(err.to_string()))?;
if bytes.is_empty() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"uploaded file is empty",
));
}
let upload_dir = inbox_root
.join("user_uploads")
.join(user.id.to_string())
.join(format!("local-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&upload_dir)
.await
.map_err(|err| cot::Error::internal(err.to_string()))?;
let destination = upload_dir.join(&filename);
tokio::fs::write(&destination, &bytes)
.await
.map_err(|err| cot::Error::internal(err.to_string()))?;
if let Some(handle) = scheduler_handle.get() {
let handle = Arc::clone(handle);
tokio::spawn(async move {
if let Err(err) = handle.trigger_job_now("inbox_discover").await {
tracing::warn!("failed to trigger inbox_discover after local upload: {err}");
}
});
}
Json(LocalUploadResponse {
ok: true,
filename,
size: bytes.len() as u64,
})
.into_response()
}
fn sanitize_upload_filename(value: &str) -> String {
let name = std::path::Path::new(value)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("upload.mp3");
let sanitized: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let trimmed = sanitized.trim().trim_matches('.').trim();
if trimmed.is_empty() {
"upload.mp3".to_string()
} else {
trimmed.to_string()
}
}
fn percent_decode_header(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let hi = hex_value(bytes[index + 1]);
let lo = hex_value(bytes[index + 2]);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi << 4) | lo);
index += 3;
} else {
out.push(bytes[index]);
index += 1;
}
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
let bytes_prefix = "bytes=";
if !header.starts_with(bytes_prefix) {
@@ -1234,7 +1431,11 @@ async fn search_handler(
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track t
JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
@@ -1299,7 +1500,7 @@ async fn search_handler(
let t = sqlx::query_as::<_, SearchTrackRow>(
r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id,
release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate,
audio_sample_rate, audio_bit_depth, file_size_bytes FROM (
audio_sample_rate, audio_bit_depth, file_size_bytes, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at FROM (
SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
t.duration_seconds, t.cover_file_id,
rel.cover_file_id AS release_cover_file_id,
@@ -1310,20 +1511,27 @@ async fn search_handler(
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at,
MAX(sim) AS similarity
FROM (
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,
similarity(title_sort, $1) AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort % $1
UNION ALL
SELECT id, title, title_sort, track_number, disc_number, duration_seconds, cover_file_id, release_id, audio_file_id,
lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at,
0.01::real AS sim
FROM furumusic__track WHERE is_hidden = false AND title_sort ILIKE '%' || $1 || '%'
) t
JOIN furumusic__release rel ON rel.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year,
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes,
t.lastfm_listeners, t.lastfm_playcount, t.lastfm_rating, t.lastfm_updated_at
ORDER BY similarity DESC
LIMIT $2
) sub"#,
@@ -1427,6 +1635,10 @@ async fn search_handler(
audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes,
lastfm_listeners: t.lastfm_listeners,
lastfm_playcount: t.lastfm_playcount,
lastfm_rating: t.lastfm_rating,
lastfm_updated_at: t.lastfm_updated_at,
}
})
.collect();
@@ -1991,7 +2203,11 @@ async fn tracks_by_ids_handler(
mf.audio_bitrate,
mf.audio_sample_rate,
mf.audio_bit_depth,
mf.file_size_bytes
mf.file_size_bytes,
t.lastfm_listeners,
t.lastfm_playcount,
t.lastfm_rating,
t.lastfm_updated_at
FROM furumusic__track t
JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
@@ -2066,6 +2282,10 @@ async fn tracks_by_ids_handler(
audio_sample_rate: t.audio_sample_rate,
audio_bit_depth: t.audio_bit_depth,
file_size_bytes: t.file_size_bytes,
lastfm_listeners: t.lastfm_listeners,
lastfm_playcount: t.lastfm_playcount,
lastfm_rating: t.lastfm_rating,
lastfm_updated_at: t.lastfm_updated_at,
},
);
}
@@ -2134,30 +2354,198 @@ impl App for PlayerApp {
},
"player_me",
),
Route::with_handler_and_name(
"/agent-queue",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
agent_queue_handler(session, db, pg_pool).await
}
})
},
"player_agent_queue",
),
// -- Torrent import widget --
Route::with_handler_and_name(
"/torrents/preview",
"/torrents",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.list(pg_pool, user.id).await {
Ok(items) => Json(items).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
}
}
}
})
},
"player_torrent_list",
),
Route::with_handler_and_name(
"/torrents/session/{id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(_user) = auth::get_session_user(&session, &db).await
else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.preview(json.0).await {
match service.details(pg_pool, user.id, &path.0.id).await {
Ok(details) => Json(details).into_response(),
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
}
}
}
}
})
.delete(
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.remove(pg_pool, user.id, &path.0.id).await {
Ok(()) => {
Json(serde_json::json!({ "ok": true })).into_response()
}
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
}
}
}
},
)
},
"player_torrent_detail",
),
Route::with_handler_and_name(
"/torrents/preview",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |session: Session, db: Database, json: Json<TorrentPreviewRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.preview(pg_pool, user.id, json.0).await {
Ok(preview) => Json(preview).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
@@ -2169,9 +2557,34 @@ impl App for PlayerApp {
},
"player_torrent_preview",
),
Route::with_handler_and_name(
"/uploads/local",
{
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
move |session: Session, db: Database, request: cot::request::Request| {
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let (live_config, _) = AppConfig::load_with_db(&db).await;
local_upload_handler(
session,
db,
live_config,
scheduler_handle,
request,
)
.await
}
},
)
},
"player_local_upload",
),
Route::with_handler_and_name(
"/torrents/{id}/start",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
post(
@@ -2179,6 +2592,8 @@ impl App for PlayerApp {
db: Database,
path: Path<PathStringId>,
json: Json<TorrentStartRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
@@ -2188,6 +2603,15 @@ impl App for PlayerApp {
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
let service = torrent_service
.get_or_init(|| async {
@@ -2196,6 +2620,7 @@ impl App for PlayerApp {
.await;
match service
.start(
pg_pool,
&path.0.id,
json.0.selected_files,
live_config.agent_inbox_dir,
@@ -2215,28 +2640,86 @@ impl App for PlayerApp {
"player_torrent_start",
),
Route::with_handler_and_name(
"/torrents/{id}/status",
"/torrents/{id}/pause",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
get(
post(
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(_user) = auth::get_session_user(&session, &db).await
else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.status(&path.0.id).await {
match service.pause(pg_pool, user.id, &path.0.id).await {
Ok(job) => Json(job).into_response(),
Err(err) => {
Ok(json_error(StatusCode::BAD_REQUEST, &err.to_string()))
}
}
}
},
)
},
"player_torrent_pause",
),
Route::with_handler_and_name(
"/torrents/{id}/status",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&self.scheduler_handle);
get(
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
let torrent_service = Arc::clone(&torrent_service);
let scheduler_handle = Arc::clone(&scheduler_handle);
async move {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"not authenticated",
));
};
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
let service = torrent_service
.get_or_init(|| async {
Arc::new(TorrentService::new(Arc::clone(&scheduler_handle)))
})
.await;
match service.status(pg_pool, user.id, &path.0.id).await {
Ok(job) => Json(job).into_response(),
Err(err) => {
Ok(json_error(StatusCode::NOT_FOUND, &err.to_string()))
+16
View File
@@ -44,6 +44,10 @@ pub(super) struct TrackRow {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
@@ -110,6 +114,10 @@ pub(super) struct PlaylistTrackRow {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
@@ -128,6 +136,10 @@ pub(super) struct AppearanceTrackRow {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
@@ -165,6 +177,10 @@ pub(super) struct SearchTrackRow {
pub(super) audio_sample_rate: Option<i32>,
pub(super) audio_bit_depth: Option<i32>,
pub(super) file_size_bytes: Option<i64>,
pub(super) lastfm_listeners: Option<i64>,
pub(super) lastfm_playcount: Option<i64>,
pub(super) lastfm_rating: Option<f64>,
pub(super) lastfm_updated_at: Option<String>,
}
#[derive(sqlx::FromRow)]
+1 -1
View File
@@ -1347,7 +1347,7 @@ async fn run_scheduled_job(
// Check agent_enabled (re-read from DB every run)
let (live_config, _) = AppConfig::load_with_db(db).await;
if !live_config.agent_enabled {
if !live_config.agent_enabled && job_name != "lastfm_popularity" {
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
return;
}
+965 -128
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -667,6 +667,24 @@ tbody tr:hover {
display: block;
}
.settings-page {
display: grid;
grid-template-columns: minmax(560px, 760px) minmax(260px, 1fr);
gap: 14px;
align-items: start;
}
.settings-card {
padding: 14px;
}
.settings-note {
padding: 14px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.55;
}
.library-row {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 300px 130px;
@@ -818,6 +836,11 @@ tbody tr:hover {
<i data-lucide="wrench"></i>
<span>Future Tools</span>
</button>
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="activeView = 'settings'; loadSettings()">
<i data-lucide="settings"></i>
<span>Settings</span>
<span class="nav-count" x-text="settings.lastfm_api_key_configured ? 'ok' : ''"></span>
</button>
</div>
<div class="nav-group">
@@ -1236,6 +1259,48 @@ tbody tr:hover {
</div>
</section>
</div>
<div class="content" x-show="activeView === 'settings'">
<div class="settings-page">
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>External APIs</strong>
<span>Keys used by scheduled enrichment jobs</span>
</div>
<span class="badge" :class="settings.lastfm_api_key_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_api_key_configured ? 'configured' : 'not configured'"></span>
</div>
<form class="settings-card" @submit.prevent="saveSettings()">
<div class="field">
<label>Last.fm API key</label>
<input type="password" x-model="settingsDraft.lastfm_api_key" autocomplete="off" placeholder="Paste Last.fm API key" />
</div>
<div class="toolbar">
<button class="btn primary" type="submit">
<i data-lucide="save"></i>
Save
</button>
<button class="btn" type="button" @click="loadSettings()">
<i data-lucide="refresh-cw"></i>
Reload
</button>
</div>
</form>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Last.fm Popularity</strong>
<span>Weekly track rating refresh</span>
</div>
</div>
<div class="settings-note">
The scheduler uses Last.fm track.getInfo for each track, stores listeners, playcount, current rating, and a history row. The job processes tracks with missing or oldest ratings first and waits between requests to avoid Last.fm API limits.
</div>
</section>
</div>
</div>
</main>
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
@@ -1366,6 +1431,8 @@ function adminV2() {
activeLibraryItem: null,
editorOpen: false,
editorDraft: { title: '', hidden: 'false' },
settings: { lastfm_api_key: '', lastfm_api_key_configured: false },
settingsDraft: { lastfm_api_key: '' },
poller: null,
async init() {
@@ -1399,6 +1466,7 @@ function adminV2() {
this.jobs = data.jobs || [];
this.recentRuns = data.recent_runs || [];
if (!this.activeJobName && this.jobs.length) this.activeJobName = this.jobs[0].name;
await this.loadSettings(false);
await this.loadLibrary(false);
} catch (error) {
this.showToast(error.message);
@@ -1462,6 +1530,32 @@ function adminV2() {
}
},
async loadSettings(showErrors = true) {
try {
this.settings = await this.request(`${this.apiBase}/settings`);
this.settingsDraft.lastfm_api_key = this.settings.lastfm_api_key || '';
} catch (error) {
if (showErrors) this.showToast(error.message);
} finally {
this.icons();
}
},
async saveSettings() {
try {
await this.request(`${this.apiBase}/settings`, {
method: 'POST',
body: JSON.stringify({
lastfm_api_key: this.settingsDraft.lastfm_api_key || ''
})
});
await this.loadSettings(false);
this.showToast('Settings saved');
} catch (error) {
this.showToast(error.message);
}
},
setReviewStatus(status) {
this.reviewFilter.status = status;
this.loadReviews();
@@ -1770,6 +1864,7 @@ function adminV2() {
if (this.activeView === 'library') return 'Library Workbench';
if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'settings') return 'Settings';
return 'Review Queue';
},
@@ -1777,6 +1872,7 @@ function adminV2() {
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, and playlists';
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
return 'Full-screen review triage with filter-aware bulk actions';
},
+4 -5117
View File
File diff suppressed because it is too large Load Diff
+344
View File
@@ -0,0 +1,344 @@
<!-- Info Modal -->
<template x-if="$store.info.modal">
<div class="modal-overlay" @click.self="$store.info.close()">
<div class="modal-box info-modal">
<div class="info-modal-head">
<h3 x-text="$store.info.modal.title"></h3>
<button class="mobile-list-action" @click="$store.info.close()" title="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<pre class="info-modal-body" x-text="$store.info.modal.body"></pre>
</div>
</div>
</template>
<!-- Create / Rename Playlist Modal -->
<template x-if="$store.playlists.modal">
<div class="modal-overlay" @click.self="$store.playlists.modal = null">
<div class="modal-box">
<h3 x-text="$store.playlists.modal.mode === 'create' ? '{{ t.player_new_playlist }}' : '{{ t.player_rename_playlist }}'"></h3>
<input type="text" x-model="$store.playlists.modal.title" placeholder="{{ t.player_playlist_name }}"
@keydown.enter="$store.playlists.submitModal()" x-init="$nextTick(() => $el.focus())">
<div class="modal-footer">
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.modal = null">{{ t.player_cancel }}</button>
<button class="modal-btn modal-btn-primary" @click="$store.playlists.submitModal()"
x-text="$store.playlists.modal.mode === 'create' ? '{{ t.player_create }}' : '{{ t.player_save }}'"></button>
</div>
</div>
</div>
</template>
<!-- Add to Playlist Modal -->
<template x-if="$store.playlists.picker">
<div class="modal-overlay" @click.self="$store.playlists.picker = null">
<div class="modal-box">
<h3>{{ t.player_add_to_playlist }}</h3>
<div class="modal-playlist-list">
<template x-for="pl in $store.playlists.list.filter(p => p.kind === 'user' && p.is_own)" :key="pl.id">
<div class="modal-playlist-item" @click="$store.playlists.addToPicked(pl.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<span x-text="pl.title"></span>
</div>
</template>
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn-ghost" @click="$store.playlists.picker = null">{{ t.player_cancel }}</button>
<button class="modal-btn modal-btn-primary" @click="$store.playlists.picker = null; $store.playlists.showCreate()">{{ t.player_new_playlist }}</button>
</div>
</div>
</div>
</template>
<!-- Torrent Import Modal -->
<template x-if="$store.torrents.modal">
<div class="modal-overlay" @click.self="$store.torrents.close()">
<div class="modal-box torrent-modal">
<div class="torrent-modal-head">
<div>
<h3>{{ t.player_torrent_manager }}</h3>
<p class="torrent-message" style="margin:4px 0 0"
:class="{ error: $store.torrents.error }"
x-text="$store.torrents.message"></p>
</div>
<button class="torrent-modal-close"
@click="$store.torrents.close()"
title="{{ t.player_close }}"
aria-label="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<div class="torrent-client-status">
<span class="torrent-status-pill"
:class="{ active: $store.torrents.activeCount() > 0 }"
x-text="$store.torrents.clientSummary()"></span>
<span class="torrent-status-pill torrent-agent-pill"
:class="{ active: $store.torrents.agentBusy() }">
<span class="torrent-agent-dot"></span>
<span x-text="$store.torrents.agentSummary()"></span>
</span>
<span class="torrent-status-pill"
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
</div>
</div>
<div class="torrent-manager-layout">
<aside class="torrent-manager-sidebar">
<div class="torrent-manager-title">
<span>{{ t.player_saved_torrents }}</span>
<button class="modal-btn modal-btn-ghost" style="padding:4px 8px"
@click="$store.torrents.loadSessions()"
:disabled="$store.torrents.loading">{{ t.player_refresh }}</button>
</div>
<div class="torrent-session-list">
<template x-if="!$store.torrents.loadingSessions && $store.torrents.sessions.length === 0">
<div class="empty-state" style="padding:28px 12px">
<p>{{ t.player_no_saved_torrents }}</p>
</div>
</template>
<template x-for="job in $store.torrents.sessions" :key="job.id">
<div class="torrent-session-row"
:class="{ active: $store.torrents.workspaceMode === 'session' && $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
@click="$store.torrents.openSession(job.id)">
<div class="torrent-session-main">
<div class="torrent-session-topline">
<div class="torrent-session-name" x-text="job.name"></div>
<span class="torrent-status-badge"
:class="$store.torrents.statusBadgeClass(job)"
x-text="$store.torrents.statusLabel(job)"></span>
</div>
<div class="torrent-session-meta" x-text="$store.torrents.sessionMeta(job)"></div>
<div class="torrent-session-progress">
<div class="torrent-session-progress-bar"
:style="'width:' + $store.torrents.progressValue(job) + '%'"></div>
</div>
</div>
</div>
</template>
<button type="button"
class="torrent-session-row torrent-session-add"
:class="{ active: $store.torrents.isImporting() }"
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_upload }}</span>
</button>
</div>
</aside>
<section class="torrent-workspace">
<template x-if="$store.torrents.workspaceMode === 'empty'">
<div class="empty-state torrent-workspace-empty">
<p x-text="T.chooseSavedOrAddTorrent"></p>
</div>
</template>
<template x-if="$store.torrents.isImporting()">
<div class="torrent-import-panel">
<div class="torrent-modal-grid">
<div>
<label for="local-file-input">{{ t.player_local_files }}</label>
<input id="local-file-input" type="file" multiple accept="audio/*,.mp3,.flac,.wav,.m4a,.ogg,.opus,.aac"
@change="$store.torrents.setLocalFiles($event.target.files)">
<div class="torrent-upload-summary" x-text="$store.torrents.localUploadSummary()"></div>
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</div>
<div>
<label for="torrent-file-input">{{ t.player_torrent_file }}</label>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
</div>
</div>
<div class="torrent-upload-progress"
x-show="$store.torrents.uploadProgress > 0 || ($store.torrents.localFiles.length > 0 && $store.torrents.loading)">
<div class="torrent-progress-head">
<span x-text="$store.torrents.uploadProgress >= 100 ? T.uploadComplete : T.uploadingFiles"></span>
<span x-text="$store.torrents.uploadProgressText"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.uploadProgress + '%'"></div>
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_upload_content }}
</button>
</div>
</div>
</template>
<template x-if="$store.torrents.currentJob">
<div class="torrent-progress-card">
<div class="torrent-progress-head">
<span x-text="$store.torrents.statusText($store.torrents.currentJob)"></span>
<span x-text="$store.torrents.progressValue($store.torrents.currentJob).toFixed(1) + '%'"></span>
</div>
<div class="torrent-progress-track">
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div>
</div>
<div class="torrent-progress-details"
:class="{ completed: $store.torrents.isCompleted($store.torrents.currentJob) }">
<span class="torrent-progress-metric">
<span class="torrent-progress-label"
x-text="$store.torrents.isCompleted($store.torrents.currentJob) ? T.size : T.downloaded"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.progressDetailText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.speed"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.peers"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob) && $store.torrents.etaText($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.eta"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.etaText($store.torrents.currentJob)"></span>
</span>
</div>
</div>
</template>
<template x-if="$store.torrents.previewData">
<div class="torrent-preview-panel">
<div class="torrent-preview-head">
<div style="min-width:0">
<div class="torrent-preview-title" x-text="$store.torrents.previewData.name"></div>
<div class="torrent-preview-meta"
x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
</div>
<div class="torrent-preview-actions">
<button class="modal-btn"
:class="$store.torrents.actionButtonClass()"
@click="$store.torrents.toggleDownloadAction()"
:disabled="$store.torrents.actionButtonDisabled()">
<span x-text="$store.torrents.actionButtonText()"></span>
</button>
<button class="modal-btn modal-btn-danger"
@click="$store.torrents.removeSession($store.torrents.previewData.id)"
:disabled="$store.torrents.loading">
{{ t.player_delete }}
</button>
</div>
</div>
<div class="torrent-tree-toolbar">
<div class="torrent-selected-summary"
x-text="$store.torrents.selected.size + ' {{ t.player_selected }} - ' + $store.torrents.bytes($store.torrents.selectedBytes())"></div>
<div class="torrent-actions" style="margin-top:0">
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(true)">{{ t.player_expand_all }}</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.expandAll(false)">{{ t.player_collapse }}</button>
</div>
</div>
<div class="torrent-file-tree">
<template x-for="node in $store.torrents.visibleNodes()" :key="node.key">
<div class="torrent-tree-row" :style="'--indent:' + $store.torrents.rowIndent(node) + 'px'">
<button class="torrent-tree-toggle"
:class="{ expanded: $store.torrents.expanded.has(node.key) }"
@click="$store.torrents.toggleExpand(node)"
:style="node.type === 'folder' ? '' : 'visibility:hidden'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<button class="torrent-tree-check"
:class="$store.torrents.nodeCheckClass(node)"
@click="$store.torrents.toggleNode(node)">
<template x-if="$store.torrents.nodeState(node) === 'checked'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</template>
<template x-if="$store.torrents.nodeState(node) === 'partial'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</template>
</button>
<div class="torrent-tree-label" :title="node.name">
<template x-if="node.type === 'folder'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 012-2h5l2 2h7a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
</template>
<template x-if="node.type === 'file'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
</template>
<span class="torrent-file-name" x-text="node.name"></span>
</div>
<span class="torrent-file-size" x-text="$store.torrents.bytes(node.size)"></span>
</div>
</template>
</div>
</div>
</template>
</section>
</div>
</div>
</div>
</template>
<!-- Play History Modal -->
<template x-if="$store.history.modal">
<div class="modal-overlay" @click.self="$store.history.close()">
<div class="modal-box history-modal">
<h3>{{ t.player_play_history }}</h3>
<p class="torrent-message" :class="{ error: $store.history.error }"
x-text="$store.history.message"></p>
<div class="history-list">
<template x-if="!$store.history.loading && $store.history.items.length === 0">
<div class="empty-state" style="padding:32px 16px">
<p>{{ t.player_no_plays_yet }}</p>
</div>
</template>
<template x-for="item in $store.history.items" :key="item.id">
<div class="history-row">
<div style="min-width:0">
<div class="history-title" x-text="item.track_title"></div>
<div class="history-release" x-text="item.release_title || '{{ t.player_unknown_release }}'"></div>
</div>
<div>
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
</div>
</div>
</template>
</div>
<div class="history-pager">
<button class="modal-btn modal-btn-ghost"
@click="$store.history.load($store.history.page - 1)"
:disabled="$store.history.loading || $store.history.page <= 1">
{{ t.player_previous }}
</button>
<span class="history-release"
x-text="'{{ t.player_page }} ' + $store.history.page + ' {{ t.player_of }} ' + $store.history.totalPages()"></span>
<button class="modal-btn modal-btn-primary"
@click="$store.history.load($store.history.page + 1)"
:disabled="$store.history.loading || $store.history.page >= $store.history.totalPages()">
{{ t.player_next }}
</button>
</div>
</div>
</div>
</template>
</div>
File diff suppressed because it is too large Load Diff
+983
View File
@@ -0,0 +1,983 @@
<div class="app-layout"
x-data
@keydown.window.space="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.toggle(); }"
@keydown.window.arrow-left="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(-5); }"
@keydown.window.arrow-right="if (!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { $event.preventDefault(); $store.player.seekRelative(5); }"
@keydown.window="if ((e=$event).ctrlKey && e.key==='k') { e.preventDefault(); document.getElementById('search-input')?.focus(); } else if (e.key==='/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); document.getElementById('search-input')?.focus(); }"
>
<div class="main-content">
<!-- Left Sidebar -->
<div class="sidebar-left">
<div class="user-widget" x-show="$store.user.profile" x-cloak>
<div class="user-widget-main">
<div class="user-avatar" x-text="$store.user.initials()"></div>
<div style="min-width:0">
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open()">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">{{ t.player_plays_count }}</span>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">{{ t.player_likes_count }}</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
</div>
<div class="sidebar-header">
<h2>{{ t.player_library }}</h2>
</div>
<div class="sidebar-nav">
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title">
{{ t.player_following }}
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">{{ t.player_no_followed_artists }}</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="artist.id">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id)">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
<div class="playlist-list">
<template x-for="pl in $store.playlists.regularList()" :key="pl.id">
<div class="playlist-item-row">
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
<template x-if="pl.kind === 'likes'">
<span style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
<span x-text="$store.playlists.displayTitle(pl)"></span>
</span>
</template>
<template x-if="pl.kind !== 'likes'">
<span x-text="$store.playlists.displayTitle(pl)"></span>
</template>
<span class="playlist-count" x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
</div>
<template x-if="pl.is_own && pl.kind === 'user'">
<div class="playlist-item-actions">
<button class="playlist-action-btn" @click.stop="$store.playlists.startRename(pl)" title="{{ t.player_rename }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="{{ t.player_delete }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</template>
</div>
</template>
<button class="sidebar-create-btn" @click="$store.playlists.showCreate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t.player_new_playlist }}
</button>
<template x-if="$store.playlists.publishedList().length > 0">
<div class="playlist-public-section">
<div class="sidebar-section-title playlist-subtitle">{{ t.player_published_playlists }}</div>
<template x-for="pl in $store.playlists.publishedList()" :key="'published-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id)">
<div class="playlist-title-line">
<span class="playlist-title-text" x-text="$store.playlists.displayTitle(pl)"></span>
<span class="playlist-public-badge">{{ t.player_public }}</span>
</div>
<div class="playlist-meta-line">
<span class="playlist-owner" x-show="pl.owner_name" x-text="'{{ t.player_by }} ' + pl.owner_name"></span>
<span x-show="pl.owner_name">&middot;</span>
<span x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
<div class="sidebar-bottom">
<a href="/admin/">{{ t.player_admin_panel }}</a>
</div>
</div>
<template x-if="$store.mobile.libraryOpen">
<div class="mobile-library-backdrop" @click.self="$store.mobile.closeLibrary()" x-cloak>
<aside class="mobile-library-drawer">
<div class="mobile-drawer-head">
<div>
<div class="mobile-drawer-title">{{ t.player_library }}</div>
<div class="playlist-count">{{ t.player_playlists }} / {{ t.player_following }}</div>
</div>
<button class="mobile-list-action" @click="$store.mobile.closeLibrary()" title="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="mobile-drawer-body">
<div class="mobile-drawer-section">
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
</div>
</div>
<div class="mobile-drawer-section">
<div class="sidebar-section-title">
{{ t.player_following }}
<span x-show="$store.follows.artists.length > 0"
x-text="'(' + $store.follows.artists.length + ')'"></span>
</div>
<template x-if="$store.follows.artists.length === 0">
<div class="following-empty">{{ t.player_no_followed_artists }}</div>
</template>
<div class="following-list" x-show="$store.follows.artists.length > 0" x-cloak>
<template x-for="artist in $store.follows.artists" :key="'mobile-follow-' + artist.id">
<div class="mobile-list-row">
<div class="following-artist"
:class="{ active: $store.library.currentArtist && $store.library.currentArtist.id === artist.id }"
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div class="following-name" x-text="artist.name"></div>
</div>
<button class="mobile-list-action"
@click.stop="$store.follows.toggle(artist.id)"
title="{{ t.player_unfollow_artist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<line x1="17" y1="11" x2="23" y2="11"/>
</svg>
</button>
</div>
</template>
</div>
</div>
<div class="mobile-drawer-section">
<div class="sidebar-section-title">{{ t.player_playlists }}</div>
<template x-for="pl in $store.playlists.regularList()" :key="'mobile-playlist-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
<template x-if="pl.kind === 'likes'">
<span style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="var(--accent)" stroke="none" width="14" height="14"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
<span x-text="$store.playlists.displayTitle(pl)"></span>
</span>
</template>
<template x-if="pl.kind !== 'likes'">
<span x-text="$store.playlists.displayTitle(pl)"></span>
</template>
<span class="playlist-count" x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
</div>
<template x-if="pl.is_own && pl.kind === 'user'">
<div class="playlist-item-actions">
<button class="playlist-action-btn" @click.stop="$store.mobile.closeLibrary(); $store.playlists.startRename(pl)" title="{{ t.player_rename }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="playlist-action-btn" @click.stop="$store.playlists.deletePlaylist(pl.id)" title="{{ t.player_delete }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</template>
</div>
</template>
<button class="sidebar-create-btn" @click="$store.mobile.closeLibrary(); $store.playlists.showCreate()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t.player_new_playlist }}
</button>
<template x-if="$store.playlists.publishedList().length > 0">
<div class="playlist-public-section">
<div class="sidebar-section-title playlist-subtitle">{{ t.player_published_playlists }}</div>
<template x-for="pl in $store.playlists.publishedList()" :key="'mobile-published-' + pl.id">
<div class="playlist-item-row">
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id); $store.mobile.closeLibrary()">
<div class="playlist-title-line">
<span class="playlist-title-text" x-text="$store.playlists.displayTitle(pl)"></span>
<span class="playlist-public-badge">{{ t.player_public }}</span>
</div>
<div class="playlist-meta-line">
<span class="playlist-owner" x-show="pl.owner_name" x-text="'{{ t.player_by }} ' + pl.owner_name"></span>
<span x-show="pl.owner_name">&middot;</span>
<span x-text="pl.track_count + ' {{ t.player_tracks_count }}'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</aside>
</div>
</template>
<!-- Center Content -->
<div class="center-content" id="center-scroll">
<!-- Search / account bar -->
<div class="content-topbar" @click.outside="$store.user.menuOpen = false">
<button class="mobile-library-btn"
@click="$store.user.menuOpen = false; $store.mobile.toggleLibrary()"
title="{{ t.player_library }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/>
<path d="M4 4.5A2.5 2.5 0 016.5 2H20v20H6.5A2.5 2.5 0 014 19.5z"/>
</svg>
</button>
<div class="search-bar">
<span class="search-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<input id="search-input" type="text" placeholder="{{ t.player_search_placeholder }}"
x-model="$store.library.searchQuery"
@input.debounce.300ms="$store.library.search($store.library.searchQuery)"
@keydown.escape="$store.library.clearSearch(); $el.blur()">
<template x-if="$store.library.searchQuery">
<button class="search-clear" @click="$store.library.clearSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</template>
<template x-if="!$store.library.searchQuery">
<span class="search-shortcut">Ctrl+K</span>
</template>
</div>
<button class="torrent-import-btn"
@click="$store.torrents.open()"
title="{{ t.player_import_torrent }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button class="mobile-account-chip"
x-show="$store.user.profile"
x-cloak
@click="$store.mobile.closeLibrary(); $store.user.menuOpen = !$store.user.menuOpen"
:title="$store.user.profile?.name || 'Account'">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<span class="mobile-account-name" x-text="$store.user.profile?.name || ''"></span>
</button>
<div class="mobile-account-popover"
x-show="$store.user.menuOpen && $store.user.profile"
x-cloak>
<div class="user-widget-main">
<span class="user-avatar" x-text="$store.user.initials()"></span>
<div style="min-width:0">
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
</div>
<div class="user-stats">
<button class="user-stat" @click="$store.history.open(); $store.user.menuOpen = false">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.plays)"></span>
<span class="user-stat-label">{{ t.player_plays_count }}</span>
</button>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.format($store.user.profile?.stats?.liked_tracks)"></span>
<span class="user-stat-label">{{ t.player_likes_count }}</span>
</div>
<div class="user-stat">
<span class="user-stat-value" x-text="$store.user.duration($store.user.profile?.stats?.listened_minutes)"></span>
<span class="user-stat-label">{{ t.player_listened }}</span>
</div>
</div>
<button class="modal-btn modal-btn-primary mobile-account-logout"
@click="$store.user.logout()">
{{ t.player_log_out }}
</button>
</div>
</div>
<!-- Search Results -->
<template x-if="$store.library.view === 'search'">
<div>
<template x-if="$store.library.searchLoading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
<div>
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>{{ t.player_no_results }}</p>
</div>
</template>
<!-- Artists section -->
<template x-if="$store.library.searchResults.artists.length > 0">
<div class="search-section">
<h2 class="search-section-title">{{ t.player_artists }}</h2>
<div class="search-artists-row">
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
<div class="search-artist-img">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
</div>
<div class="search-artist-name" x-text="artist.name"></div>
</div>
</template>
</div>
</div>
</template>
<!-- Releases section -->
<template x-if="$store.library.searchResults.releases.length > 0">
<div class="search-section">
<h2 class="search-section-title">{{ t.player_releases }}</h2>
<div class="search-releases-row">
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
<div class="search-release-cover" style="position:relative">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.release_type"></span>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Tracks section -->
<template x-if="$store.library.searchResults.tracks.length > 0">
<div class="search-section">
<h2 class="search-section-title">{{ t.player_tracks }}</h2>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.library.playSearchTrack(idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Artists Grid -->
<template x-if="$store.library.view === 'artists'">
<div>
<h1 class="section-title">{{ t.player_artists }}</h1>
<div class="card-grid">
<template x-for="artist in $store.library.artists" :key="artist.id">
<div class="card" @click="$store.library.openArtist(artist.id)">
<div class="card-img">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
</template>
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
</div>
<div class="card-title" x-text="artist.name"></div>
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
</div>
</template>
</div>
<template x-if="$store.library.loading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<div id="artist-sentinel" style="height:1px"></div>
</div>
</template>
<!-- Artist Detail -->
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
<span>/</span>
<span x-text="$store.library.currentArtist.name"></span>
</div>
<div class="artist-header">
<div class="artist-img">
<template x-if="$store.library.currentArtist.image_url">
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
</template>
<template x-if="!$store.library.currentArtist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
</div>
<div>
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
<div class="artist-stats">
<span x-text="$store.library.currentArtist.releases.length + ' {{ t.player_releases_count }}'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_track_count + ' {{ t.player_tracks_count }}'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' {{ t.player_plays_count }}'"></span>
</div>
<div class="release-actions">
<button class="release-action-btn secondary"
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
@click="$store.follows.toggle($store.library.currentArtist.id)"
:title="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has($store.library.currentArtist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has($store.library.currentArtist.id)" d="M16 11l2 2 4-5"/>
</svg>
<span x-text="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_followed }}' : '{{ t.player_follow }}'"></span>
</button>
</div>
</div>
</div>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2>
<div class="card-grid">
<template x-for="release in group.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-info-btn" @click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo(release))" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.track_count + ' {{ t.player_tracks_count }}'"></span>
</div>
</div>
</template>
</div>
</section>
</template>
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_appears_on }}</h2>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</section>
</template>
</div>
</template>
<!-- Release Detail -->
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
<span>/</span>
<template x-if="$store.library.currentRelease.artists.length > 0">
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
</template>
<span>/</span>
<span x-text="$store.library.currentRelease.title"></span>
</div>
<div class="release-header">
<div class="release-cover">
<template x-if="$store.library.currentRelease.cover_url">
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
</template>
<template x-if="!$store.library.currentRelease.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</div>
<div class="release-meta">
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
<div class="release-title-row">
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
<button class="like-btn like-btn-lg release-title-like"
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
</div>
<div class="release-artists">
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
</span>
</template>
</div>
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
<button class="release-action-btn secondary"
@click.stop="$store.info.open('{{ t.player_release_info }}', $store.library.releaseInfo($store.library.currentRelease))"
:title="$store.library.releaseInfo($store.library.currentRelease)"
aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
{{ t.player_info }}
</button>
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ t.player_play }}
</button>
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t.player_queue }}
</button>
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
{{ t.player_next }}
</button>
</div>
</div>
</div>
<!-- Track list -->
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
<!-- Playlist Detail -->
<template x-if="$store.library.view === 'playlist_detail' && $store.library.currentPlaylist">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">{{ t.player_library }}</a>
<span>/</span>
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
</div>
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
<div class="playlist-detail-meta"
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
<span x-show="$store.library.currentPlaylist.owner_name"
x-text="'{{ t.player_by }} ' + $store.library.currentPlaylist.owner_name"></span>
<span x-show="$store.library.currentPlaylist.owner_name && $store.library.currentPlaylist.is_public">&middot;</span>
<span class="playlist-public-badge"
x-show="$store.library.currentPlaylist.is_public">{{ t.player_published }}</span>
</div>
<template x-if="$store.library.currentPlaylist.description">
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
</template>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
<span></span>
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="{{ t.player_play }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</button>
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="track-action-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
</div>
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
</div>
</template>
</div>
</template>
</div>
<!-- Queue Panel -->
<div class="queue-backdrop"
x-show="$store.queue.visible"
x-cloak
@click="$store.queue.visible = false"></div>
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
<div class="queue-header">
<h3>{{ t.player_queue }}</h3>
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
</div>
<div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>{{ t.player_queue_empty }}</p>
</div>
</template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
<div class="queue-track"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx }"
@click="$store.queue.playFromIndex(idx)"
draggable="true"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle" @mousedown.stop>
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div>
<div class="queue-track-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div>
<div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div>
<div class="queue-track-actions">
<button class="queue-track-remove info-btn" @click.stop="$store.info.open('{{ t.player_track_info }}', $store.library.trackInfo(track))" :title="$store.library.trackInfo(track)" aria-label="{{ t.player_track_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
</button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Player Bar -->
<div class="player-bar">
<div class="player-now-playing">
<template x-if="$store.player.currentTrack">
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
<div class="player-cover">
<template x-if="$store.player.currentTrack.cover_url">
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
</template>
<template x-if="!$store.player.currentTrack.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="player-track-info">
<div class="player-track-title" x-text="$store.player.currentTrack.title"></div>
<div class="player-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
<template x-if="$store.player.currentTrack.release_year">
<span class="player-release-year" x-text="' · ' + $store.player.currentTrack.release_year"></span>
</template>
</div>
</div>
</div>
</template>
</div>
<div class="player-controls">
<div class="player-buttons">
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
</button>
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
<template x-if="!$store.player.isPlaying">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</template>
<template x-if="$store.player.isPlaying">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
</template>
</button>
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
<template x-if="$store.player.repeatMode !== 'one'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/></svg>
</template>
<template x-if="$store.player.repeatMode === 'one'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 014-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 01-4 4H3"/><text x="12" y="14" font-size="8" fill="currentColor" text-anchor="middle" font-weight="bold">1</text></svg>
</template>
</button>
</div>
<div class="player-timeline">
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
<div class="progress-bar-thumb"></div>
</div>
</div>
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
</div>
<div class="player-version-chip">v{{ t.app_version() }}</div>
</div>
<div class="player-right">
<div class="volume-control">
<button class="volume-btn" @click="$store.player.toggleMute()">
<template x-if="$store.player.volume === 0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
</template>
<template x-if="$store.player.volume > 0 && $store.player.volume < 0.5">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
</template>
<template x-if="$store.player.volume >= 0.5">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/></svg>
</template>
</button>
<div class="volume-slider"
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
aria-label="{{ t.player_volume }}">
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'">
<div class="volume-slider-thumb"></div>
</div>
</div>
</div>
<button class="queue-toggle-btn" :class="{ active: $store.queue.visible }" @click="$store.queue.visible = !$store.queue.visible" title="{{ t.player_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
</div>
</div>
File diff suppressed because it is too large Load Diff