feat(media): add Jellyfin Quick Connect and harden remote connections

Add cancellable Quick Connect pairing and shared connection guards for Jellyfin and Subsonic. Bound connection attempts, validate library access before saving credentials, and report per-provider connection and synchronization status.

cx
This commit is contained in:
vicrodh committed 2026-09-09 17:12:45 -06:00
1 parent a097b7bf76
commit 75b8eee1c6
25 files changed
+1863 -90

No files matched your search

@@ -0,0 +1,295 @@
//! Per-provider single-flight connection state. No credentials or URLs belong here.
use super::media_servers::MediaServerKind;
use serde::Serialize;
use std::sync::{Arc, Mutex};
use tokio::sync::watch;
#[derive(Clone, Default, Serialize)]
pub struct Status {
pub busy: bool,
pub phase: String,
pub error: String,
pub syncing: bool,
pub progress: String,
pub pairing_code: String,
}
#[derive(Default)]
struct Slot {
generation: u64,
status: Status,
cancel: Option<watch::Sender<bool>>,
}
#[derive(Clone, Default)]
pub struct ConnectionGate(Arc<Mutex<[Slot; 2]>>);
fn index(kind: MediaServerKind) -> usize {
match kind {
MediaServerKind::Jellyfin => 0,
MediaServerKind::Subsonic => 1,
}
}
pub struct Attempt {
gate: ConnectionGate,
kind: MediaServerKind,
generation: u64,
cancel: watch::Receiver<bool>,
}
impl ConnectionGate {
pub fn begin(&self, kind: MediaServerKind, phase: &str) -> Option<Attempt> {
let mut slots = self.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(kind)];
if s.status.busy || s.status.syncing {
return None;
}
s.generation += 1;
let (tx, rx) = watch::channel(false);
s.cancel = Some(tx);
s.status.busy = true;
s.status.phase = phase.into();
s.status.error.clear();
s.status.pairing_code.clear();
Some(Attempt {
gate: self.clone(),
kind,
generation: s.generation,
cancel: rx,
})
}
pub fn cancel(&self, kind: MediaServerKind) {
let mut slots = self.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(kind)];
s.generation += 1;
if let Some(tx) = s.cancel.take() {
let _ = tx.send(true);
}
s.status = Status::default();
}
pub fn cancel_pairing(&self, kind: MediaServerKind) {
let mut slots = self.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(kind)];
if s.status.busy && s.status.phase.starts_with("pairing") {
s.generation += 1;
if let Some(tx) = s.cancel.take() {
let _ = tx.send(true);
}
s.status = Status::default();
}
}
pub fn status(&self, kind: MediaServerKind) -> Status {
self.0.lock().unwrap_or_else(|e| e.into_inner())[index(kind)]
.status
.clone()
}
pub fn begin_sync(&self, kind: MediaServerKind) -> bool {
let mut slots = self.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(kind)];
if s.status.syncing || (s.status.busy && s.status.phase != "syncing") {
return false;
}
s.status.syncing = true;
true
}
pub fn sync(&self, kind: MediaServerKind, syncing: bool) {
let mut slots = self.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(kind)];
s.status.syncing = syncing;
if !syncing {
s.status.progress.clear();
}
}
pub fn progress(&self, kind: MediaServerKind, progress: String) {
self.0.lock().unwrap_or_else(|e| e.into_inner())[index(kind)]
.status
.progress = progress;
}
}
impl Attempt {
pub fn kind(&self) -> MediaServerKind {
self.kind
}
/// Serializes the final write with cancellation/account changes.
pub fn commit<T>(&self, write: impl FnOnce() -> T) -> Option<T> {
let slots = self.gate.0.lock().unwrap_or_else(|e| e.into_inner());
(slots[index(self.kind)].generation == self.generation).then(write)
}
pub fn current(&self) -> bool {
self.commit(|| ()).is_some()
}
pub fn phase(&self, phase: &str, error: &str) {
let mut slots = self.gate.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(self.kind)];
if s.generation == self.generation {
s.status.phase = phase.into();
s.status.error = error.into();
if phase != "pairing-waiting" {
s.status.pairing_code.clear();
}
}
}
pub fn pairing_code(&self, code: &str) {
let mut slots = self.gate.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(self.kind)];
if s.generation == self.generation {
s.status.pairing_code = code.into();
}
}
pub async fn cancelled(&mut self) {
if *self.cancel.borrow() {
return;
}
let _ = self.cancel.changed().await;
}
pub async fn run<T, E>(
&self,
future: impl std::future::Future<Output = Result<T, E>>,
limit: std::time::Duration,
) -> RequestOutcome<T, E> {
let mut cancel = self.cancel.clone();
if !self.current() {
return RequestOutcome::Cancelled;
}
tokio::select! {
biased;
_ = cancel.changed() => RequestOutcome::Cancelled,
result = tokio::time::timeout(limit, future) => {
if !self.current() { return RequestOutcome::Cancelled; }
match result {
Ok(result) => RequestOutcome::Completed(result),
Err(_) => RequestOutcome::TimedOut,
}
}
}
}
}
pub enum RequestOutcome<T, E> {
Completed(Result<T, E>),
Cancelled,
TimedOut,
}
impl Drop for Attempt {
fn drop(&mut self) {
let mut slots = self.gate.0.lock().unwrap_or_else(|e| e.into_inner());
let s = &mut slots[index(self.kind)];
if s.generation == self.generation {
s.status.busy = false;
s.status.pairing_code.clear();
if matches!(
s.status.phase.as_str(),
"testing"
| "authenticating"
| "verifying"
| "syncing"
| "pairing-start"
| "pairing-waiting"
| "pairing-verifying"
) {
s.status.phase.clear();
}
s.cancel = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairing_cancellation_clears_code_and_cannot_cancel_password_or_sync() {
let gate = ConnectionGate::default();
let kind = MediaServerKind::Jellyfin;
let old = gate.begin(kind, "pairing-waiting").unwrap();
old.pairing_code("123456");
assert!(gate.begin(kind, "authenticating").is_none());
gate.cancel_pairing(kind);
assert!(gate.status(kind).pairing_code.is_empty());
let new = gate.begin(kind, "authenticating").unwrap();
old.pairing_code("old");
gate.cancel_pairing(kind);
assert!(new.current());
assert!(gate.status(kind).pairing_code.is_empty());
new.phase("syncing", "");
gate.cancel_pairing(kind);
assert!(new.current());
}
#[test]
fn single_flight_per_provider_and_retry() {
let gate = ConnectionGate::default();
for kind in MediaServerKind::ALL {
let first = gate.begin(kind, "authenticating").unwrap();
assert!(gate.begin(kind, "testing").is_none());
drop(first);
assert!(gate.status(kind).phase.is_empty());
assert!(gate.begin(kind, "authenticating").is_some());
}
let _jf = gate
.begin(MediaServerKind::Jellyfin, "authenticating")
.unwrap();
assert!(gate.begin(MediaServerKind::Subsonic, "testing").is_some());
}
#[test]
fn cancelled_completion_cannot_write_or_release_new_attempt() {
for kind in MediaServerKind::ALL {
let gate = ConnectionGate::default();
let old = gate.begin(kind, "authenticating").unwrap();
gate.cancel(kind);
let new = gate.begin(kind, "authenticating").unwrap();
assert!(old.commit(|| panic!("stale credentials written")).is_none());
old.phase("connected", "old error");
drop(old);
assert!(gate.status(kind).busy);
assert_eq!(gate.status(kind).phase, "authenticating");
assert!(new.current());
}
}
#[tokio::test]
async fn cancellation_wakes_pending_request() {
let gate = ConnectionGate::default();
let mut attempt = gate.begin(MediaServerKind::Jellyfin, "testing").unwrap();
gate.cancel(MediaServerKind::Jellyfin);
tokio::time::timeout(std::time::Duration::from_millis(100), attempt.cancelled())
.await
.unwrap();
}
#[tokio::test]
async fn timeout_releases_attempt_and_allows_retry() {
for kind in MediaServerKind::ALL {
let gate = ConnectionGate::default();
let attempt = gate.begin(kind, "authenticating").unwrap();
let out = attempt
.run(
std::future::pending::<Result<(), String>>(),
std::time::Duration::from_millis(10),
)
.await;
assert!(matches!(out, RequestOutcome::TimedOut));
drop(attempt);
assert!(gate.begin(kind, "authenticating").is_some());
}
}
#[test]
fn sync_and_sign_in_cannot_overlap() {
let gate = ConnectionGate::default();
let attempt = gate
.begin(MediaServerKind::Jellyfin, "authenticating")
.unwrap();
assert!(!gate.begin_sync(MediaServerKind::Jellyfin));
attempt.phase("syncing", "");
assert!(gate.begin_sync(MediaServerKind::Jellyfin));
assert!(!gate.begin_sync(MediaServerKind::Jellyfin));
}
#[test]
fn background_sync_blocks_auth_only_for_same_provider() {
let gate = ConnectionGate::default();
gate.sync(MediaServerKind::Jellyfin, true);
assert!(gate
.begin(MediaServerKind::Jellyfin, "authenticating")
.is_none());
assert!(gate
.begin(MediaServerKind::Subsonic, "authenticating")
.is_some());
gate.sync(MediaServerKind::Jellyfin, false);
assert!(gate
.begin(MediaServerKind::Jellyfin, "authenticating")
.is_some());
}
}
+2
View File
@@ -23,3 +23,5 @@ pub mod search_ranking;
pub mod search_service; pub mod search_service;
pub mod subscription; pub mod subscription;
pub mod tray; pub mod tray;
pub mod media_connection;
@@ -6935,3 +6935,76 @@ msgstr "Aktualisierungsdatum"
msgid "Track count" msgid "Track count"
msgstr "Titelanzahl" msgstr "Titelanzahl"
#. Media server connection status
msgid "Checking server…"
msgstr "Server wird geprüft…"
#. Media server connection status
msgid "Signing in…"
msgstr "Anmeldung läuft…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Bibliothekszugriff wird geprüft…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Server erreichbar. Zum Verbinden anmelden."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Server erreichbar. Zugangsdaten wurden nicht geprüft."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Verbunden. Bibliothek wird synchronisiert…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Verbunden. Bibliothek ist aktuell."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Verbunden. Der Server aktualisiert seine Bibliothek."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Verbindung gespeichert. Zum Prüfen des Zugriffs synchronisieren."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "Zeitüberschreitung. Serveradresse prüfen und erneut versuchen."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Anmeldung fehlgeschlagen: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Angemeldet, aber Bibliothekszugriff fehlgeschlagen: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Bibliothekssynchronisierung fehlgeschlagen: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Quick-Connect-Code wird angefordert…"
msgid "Waiting for authorization…"
msgstr "Warten auf Autorisierung…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect ist auf diesem Server deaktiviert."
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect ist abgelaufen. Fordere einen neuen Code an."
msgid "Quick Connect failed: {}"
msgstr "Quick Connect fehlgeschlagen: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Gib diesen Code unter Einstellungen > Quick Connect in einem bereits angemeldeten Jellyfin-Client ein. QBZ wartet bis zu 5 Minuten."
@@ -2535,3 +2535,76 @@ msgstr "Release date"
msgid "Label" msgid "Label"
msgstr "Label" msgstr "Label"
#. Media server connection status
msgid "Checking server…"
msgstr "Checking server…"
#. Media server connection status
msgid "Signing in…"
msgstr "Signing in…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Checking library access…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Server reachable. Sign in to connect."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Server reachable. Credentials have not been checked."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Connected. Syncing library…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Connected. Library is up to date."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Connected. The server is refreshing its library."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Saved connection. Sync to check access."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "Connection timed out. Check the server address and try again."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Sign-in failed: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Signed in, but library access failed: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Library sync failed: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Requesting Quick Connect code…"
msgid "Waiting for authorization…"
msgstr "Waiting for authorization…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect is disabled on this server."
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect expired. Request a new code."
msgid "Quick Connect failed: {}"
msgstr "Quick Connect failed: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
@@ -6941,3 +6941,76 @@ msgstr "Fecha de actualización"
msgid "Track count" msgid "Track count"
msgstr "Número de pistas" msgstr "Número de pistas"
#. Media server connection status
msgid "Checking server…"
msgstr "Comprobando servidor…"
#. Media server connection status
msgid "Signing in…"
msgstr "Iniciando sesión…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Comprobando acceso a la biblioteca…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Servidor accesible. Inicia sesión para conectarte."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Servidor accesible. No se han comprobado las credenciales."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Conectado. Sincronizando biblioteca…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Conectado. Biblioteca actualizada."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Conectado. El servidor está actualizando su biblioteca."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Conexión guardada. Sincroniza para comprobar el acceso."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "La conexión agotó el tiempo de espera. Revisa la dirección del servidor e inténtalo de nuevo."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "No se pudo iniciar sesión: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Sesión iniciada, pero falló el acceso a la biblioteca: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Falló la sincronización de la biblioteca: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Solicitando código de Quick Connect…"
msgid "Waiting for authorization…"
msgstr "Esperando autorización…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect está desactivado en este servidor."
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect caducó. Solicita un código nuevo."
msgid "Quick Connect failed: {}"
msgstr "Quick Connect falló: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Ingresa este código en Ajustes > Quick Connect desde un cliente Jellyfin con sesión iniciada. QBZ espera hasta 5 minutos."
@@ -6933,3 +6933,76 @@ msgstr "Date de modification"
msgid "Track count" msgid "Track count"
msgstr "Nombre de pistes" msgstr "Nombre de pistes"
#. Media server connection status
msgid "Checking server…"
msgstr "Vérification du serveur…"
#. Media server connection status
msgid "Signing in…"
msgstr "Connexion en cours…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Vérification de l’accès à la bibliothèque…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Serveur accessible. Connectez-vous pour continuer."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Serveur accessible. Les identifiants n’ont pas été vérifiés."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Connecté. Synchronisation de la bibliothèque…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Connecté. La bibliothèque est à jour."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Connecté. Le serveur actualise sa bibliothèque."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Connexion enregistrée. Synchronisez pour vérifier l’accès."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "Délai de connexion dépassé. Vérifiez l’adresse du serveur et réessayez."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Échec de la connexion : {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Connecté, mais l’accès à la bibliothèque a échoué : {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Échec de la synchronisation de la bibliothèque : {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Demande du code Quick Connect…"
msgid "Waiting for authorization…"
msgstr "En attente d’autorisation…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect est désactivé sur ce serveur."
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect a expiré. Demandez un nouveau code."
msgid "Quick Connect failed: {}"
msgstr "Échec de Quick Connect : {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Saisissez ce code dans Paramètres > Quick Connect depuis un client Jellyfin déjà connecté. QBZ attend jusqu’à 5 minutes."
@@ -6916,3 +6916,76 @@ msgstr "更新日"
msgid "Track count" msgid "Track count"
msgstr "曲数" msgstr "曲数"
#. Media server connection status
msgid "Checking server…"
msgstr "サーバーを確認中…"
#. Media server connection status
msgid "Signing in…"
msgstr "サインイン中…"
#. Media server connection status
msgid "Checking library access…"
msgstr "ライブラリへのアクセスを確認中…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "サーバーに到達できました。接続するにはサインインしてください。"
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "サーバーに到達できました。認証情報は未確認です。"
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "接続済み。ライブラリを同期中…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "接続済み。ライブラリは最新です。"
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "接続済み。サーバーがライブラリを更新中です。"
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "接続情報は保存済みです。同期してアクセスを確認してください。"
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "接続がタイムアウトしました。サーバーのアドレスを確認して再試行してください。"
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "サインインに失敗しました: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "サインインしましたが、ライブラリへのアクセスに失敗しました: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "ライブラリの同期に失敗しました: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Quick Connect コードを取得中…"
msgid "Waiting for authorization…"
msgstr "認証を待っています…"
msgid "Quick Connect is disabled on this server."
msgstr "このサーバーでは Quick Connect が無効です。"
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect の有効期限が切れました。新しいコードを取得してください。"
msgid "Quick Connect failed: {}"
msgstr "Quick Connect に失敗しました: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "ログイン済みの Jellyfin クライアントで「設定 > Quick Connect」にこのコードを入力してください。QBZ は最大5分間待機します。"
@@ -6923,3 +6923,76 @@ msgstr "Datum bijgewerkt"
msgid "Track count" msgid "Track count"
msgstr "Aantal nummers" msgstr "Aantal nummers"
#. Media server connection status
msgid "Checking server…"
msgstr "Server controleren…"
#. Media server connection status
msgid "Signing in…"
msgstr "Aanmelden…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Bibliotheektoegang controleren…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Server bereikbaar. Meld je aan om te verbinden."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Server bereikbaar. Aanmeldgegevens zijn niet gecontroleerd."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Verbonden. Bibliotheek synchroniseren…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Verbonden. Bibliotheek is bijgewerkt."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Verbonden. De server werkt zijn bibliotheek bij."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Verbinding opgeslagen. Synchroniseer om de toegang te controleren."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "Verbinding verlopen. Controleer het serveradres en probeer het opnieuw."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Aanmelden mislukt: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Aangemeld, maar toegang tot de bibliotheek mislukt: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Bibliotheeksynchronisatie mislukt: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Quick Connect-code aanvragen…"
msgid "Waiting for authorization…"
msgstr "Wachten op autorisatie…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect is uitgeschakeld op deze server."
msgid "Quick Connect expired. Request a new code."
msgstr "Quick Connect is verlopen. Vraag een nieuwe code aan."
msgid "Quick Connect failed: {}"
msgstr "Quick Connect mislukt: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Voer deze code in bij Instellingen > Quick Connect in een Jellyfin-client die al is aangemeld. QBZ wacht maximaal 5 minuten."
@@ -6933,3 +6933,76 @@ msgstr "Data de atualização"
msgid "Track count" msgid "Track count"
msgstr "Número de faixas" msgstr "Número de faixas"
#. Media server connection status
msgid "Checking server…"
msgstr "Verificando servidor…"
#. Media server connection status
msgid "Signing in…"
msgstr "Entrando…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Verificando acesso à biblioteca…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Servidor acessível. Entre para conectar."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Servidor acessível. As credenciais não foram verificadas."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Conectado. Sincronizando biblioteca…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Conectado. Biblioteca atualizada."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Conectado. O servidor está atualizando sua biblioteca."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Conexão salva. Sincronize para verificar o acesso."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "A conexão excedeu o tempo limite. Verifique o endereço do servidor e tente novamente."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Falha ao entrar: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Sessão iniciada, mas o acesso à biblioteca falhou: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Falha na sincronização da biblioteca: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Solicitando código do Quick Connect…"
msgid "Waiting for authorization…"
msgstr "Aguardando autorização…"
msgid "Quick Connect is disabled on this server."
msgstr "O Quick Connect está desativado neste servidor."
msgid "Quick Connect expired. Request a new code."
msgstr "O Quick Connect expirou. Solicite um novo código."
msgid "Quick Connect failed: {}"
msgstr "Falha no Quick Connect: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Insira este código em Configurações > Quick Connect em um cliente Jellyfin já conectado. O QBZ aguarda até 5 minutos."
@@ -6954,3 +6954,76 @@ msgstr "Дата обновления"
msgid "Track count" msgid "Track count"
msgstr "Количество треков" msgstr "Количество треков"
#. Media server connection status
msgid "Checking server…"
msgstr "Проверка сервера…"
#. Media server connection status
msgid "Signing in…"
msgstr "Вход…"
#. Media server connection status
msgid "Checking library access…"
msgstr "Проверка доступа к библиотеке…"
#. Media server connection status
msgid "Server reachable. Sign in to connect."
msgstr "Сервер доступен. Войдите для подключения."
#. Media server connection status
msgid "Server reachable. Credentials have not been checked."
msgstr "Сервер доступен. Учётные данные не проверены."
#. Media server connection status
msgid "Connected. Syncing library…"
msgstr "Подключено. Синхронизация библиотеки…"
#. Media server connection status
msgid "Connected. Library is up to date."
msgstr "Подключено. Библиотека обновлена."
#. Media server connection status
msgid "Connected. The server is refreshing its library."
msgstr "Подключено. Сервер обновляет библиотеку."
#. Media server connection status
msgid "Saved connection. Sync to check access."
msgstr "Подключение сохранено. Синхронизируйте для проверки доступа."
#. Media server connection status
msgid "Connection timed out. Check the server address and try again."
msgstr "Время ожидания подключения истекло. Проверьте адрес сервера и повторите попытку."
#. Media server connection status
msgid "Sign-in failed: {}"
msgstr "Ошибка входа: {}"
#. Media server connection status
msgid "Signed in, but library access failed: {}"
msgstr "Вход выполнен, но доступ к библиотеке не удался: {}"
#. Media server connection status
msgid "Library sync failed: {}"
msgstr "Ошибка синхронизации библиотеки: {}"
msgid "Quick Connect"
msgstr "Quick Connect"
msgid "Requesting Quick Connect code…"
msgstr "Запрос кода Quick Connect…"
msgid "Waiting for authorization…"
msgstr "Ожидание авторизации…"
msgid "Quick Connect is disabled on this server."
msgstr "Quick Connect отключён на этом сервере."
msgid "Quick Connect expired. Request a new code."
msgstr "Срок действия Quick Connect истёк. Запросите новый код."
msgid "Quick Connect failed: {}"
msgstr "Ошибка Quick Connect: {}"
msgid "Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes."
msgstr "Введите этот код в разделе «Настройки > Quick Connect» клиента Jellyfin, в котором уже выполнен вход. QBZ ждёт до 5 минут."
+2 -1
View File
@@ -14,8 +14,9 @@ reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }
[dev-dependencies]
# reqwest is built with `rustls-tls-webpki-roots-no-provider`, so a # reqwest is built with `rustls-tls-webpki-roots-no-provider`, so a
# `reqwest::Client` cannot be CONSTRUCTED until a process-level CryptoProvider # `reqwest::Client` cannot be CONSTRUCTED until a process-level CryptoProvider
# is installed. The app does that in `qbz_app::install_crypto_provider`; a test # is installed. The app does that in `qbz_app::install_crypto_provider`; a test
+153 -2
View File
@@ -86,6 +86,10 @@ pub enum JellyfinError {
Transport(String), Transport(String),
/// 401/403 — the token is gone or the user was disabled. /// 401/403 — the token is gone or the user was disabled.
Unauthorized, Unauthorized,
/// The password sign-in endpoint rejected the attempt.
AuthenticationRejected,
QuickConnectDisabled,
QuickConnectExpired,
/// Any other non-success status. /// Any other non-success status.
Status(u16), Status(u16),
/// The body was not the shape this client expects. /// The body was not the shape this client expects.
@@ -98,7 +102,19 @@ impl std::fmt::Display for JellyfinError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
JellyfinError::Transport(e) => write!(f, "jellyfin request failed: {e}"), JellyfinError::Transport(e) => write!(f, "jellyfin request failed: {e}"),
JellyfinError::Unauthorized => write!(f, "jellyfin rejected the credentials"), JellyfinError::Unauthorized => write!(
f,
"jellyfin rejected access to this resource (HTTP 401/403)"
),
JellyfinError::AuthenticationRejected => {
write!(f, "jellyfin rejected the sign-in (HTTP 401/403)")
}
JellyfinError::QuickConnectDisabled => {
write!(f, "Quick Connect is disabled on this server.")
}
JellyfinError::QuickConnectExpired => {
write!(f, "Quick Connect expired. Request a new code.")
}
JellyfinError::Status(s) => write!(f, "jellyfin answered {s}"), JellyfinError::Status(s) => write!(f, "jellyfin answered {s}"),
JellyfinError::Decode(e) => write!(f, "jellyfin response not understood: {e}"), JellyfinError::Decode(e) => write!(f, "jellyfin response not understood: {e}"),
JellyfinError::NotFound(what) => write!(f, "jellyfin has no {what}"), JellyfinError::NotFound(what) => write!(f, "jellyfin has no {what}"),
@@ -486,6 +502,7 @@ fn auth_header(device_id: &str, token: Option<&str>) -> String {
fn client() -> Result<reqwest::Client> { fn client() -> Result<reqwest::Client> {
reqwest::Client::builder() reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(HTTP_TIMEOUT) .timeout(HTTP_TIMEOUT)
.build() .build()
.map_err(|e| JellyfinError::Transport(e.to_string())) .map_err(|e| JellyfinError::Transport(e.to_string()))
@@ -551,7 +568,10 @@ pub async fn authenticate(
.send() .send()
.await .await
.map_err(|e| transport_error(&e))?; .map_err(|e| transport_error(&e))?;
check(resp.status())?; check(resp.status()).map_err(|e| match e {
JellyfinError::Unauthorized => JellyfinError::AuthenticationRejected,
other => other,
})?;
let dto: AuthDto = resp let dto: AuthDto = resp
.json() .json()
.await .await
@@ -564,6 +584,137 @@ pub async fn authenticate(
}) })
} }
/// A pending pairing request. The secret stays private and is never formatted.
pub struct QuickConnect {
http: reqwest::Client,
base: String,
device_id: String,
secret: String,
code: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct QuickConnectDto {
authenticated: bool,
#[serde(default)]
secret: String,
#[serde(default)]
code: String,
}
impl QuickConnect {
pub async fn start(base_url: &str, device_id: &str) -> Result<Self> {
let http = client()?;
let base = normalize_base_url(base_url);
let response = http
.get(format!("{base}/QuickConnect/Enabled"))
.send()
.await
.map_err(|e| transport_error(&e))?;
check(response.status())?;
let enabled: bool = response
.json()
.await
.map_err(|_| JellyfinError::Decode("invalid Quick Connect availability".into()))?;
if !enabled {
return Err(JellyfinError::QuickConnectDisabled);
}
let response = http
.post(format!("{base}/QuickConnect/Initiate"))
.header("Authorization", auth_header(device_id, None))
.send()
.await
.map_err(|e| transport_error(&e))?;
quick_connect_check(response.status())?;
let dto: QuickConnectDto = response
.json()
.await
.map_err(|_| JellyfinError::Decode("invalid Quick Connect response".into()))?;
if dto.secret.is_empty() || dto.code.is_empty() {
return Err(JellyfinError::Decode(
"missing Quick Connect secret or code".into(),
));
}
Ok(Self {
http,
base,
device_id: device_id.into(),
secret: dto.secret,
code: dto.code,
})
}
pub fn code(&self) -> &str {
&self.code
}
pub async fn authorized(&self) -> Result<bool> {
let response = self
.http
.get(format!("{}/QuickConnect/Connect", self.base))
.query(&[("secret", &self.secret)])
.send()
.await
.map_err(|e| transport_error(&e))?;
quick_connect_check(response.status())?;
let dto: QuickConnectDto = response
.json()
.await
.map_err(|_| JellyfinError::Decode("invalid Quick Connect status".into()))?;
Ok(dto.authenticated)
}
/// Sequential polling: no overlapping requests, bounded even if the server
/// keeps returning pending forever. Dropping this future cancels polling.
pub async fn wait_for_authorization(&self, limit: Duration) -> Result<Session> {
tokio::time::timeout(limit, async {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
if self.authorized().await? {
return self.authenticate().await;
}
}
})
.await
.map_err(|_| JellyfinError::QuickConnectExpired)?
}
pub async fn authenticate(&self) -> Result<Session> {
let response = self
.http
.post(format!("{}/Users/AuthenticateWithQuickConnect", self.base))
.header("Authorization", auth_header(&self.device_id, None))
.json(&serde_json::json!({"Secret": self.secret}))
.send()
.await
.map_err(|e| transport_error(&e))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(JellyfinError::QuickConnectExpired);
}
check(response.status()).map_err(|error| match error {
JellyfinError::Unauthorized => JellyfinError::AuthenticationRejected,
other => other,
})?;
let dto: AuthDto = response
.json()
.await
.map_err(|_| JellyfinError::Decode("invalid Quick Connect session".into()))?;
if dto.access_token.is_empty() || dto.user.id.is_empty() {
return Err(JellyfinError::Decode(
"missing Quick Connect session credentials".into(),
));
}
Ok(Session {
access_token: dto.access_token,
user_id: dto.user.id,
user_name: dto.user.name,
server_id: dto.server_id,
})
}
}
fn quick_connect_check(status: reqwest::StatusCode) -> Result<()> {
match status.as_u16() {
401 => Err(JellyfinError::QuickConnectDisabled),
404 => Err(JellyfinError::QuickConnectExpired),
_ => check(status),
}
}
impl JellyfinClient { impl JellyfinClient {
/// Build a client over an EXISTING token (the stored-credentials path). /// Build a client over an EXISTING token (the stored-credentials path).
pub fn new(base_url: &str, token: &str, user_id: &str) -> Result<Self> { pub fn new(base_url: &str, token: &str, user_id: &str) -> Result<Self> {
+156
View File
@@ -0,0 +1,156 @@
use qbz_jellyfin::{JellyfinError, QuickConnect};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn server(
responses: Vec<(&'static str, u16, &'static str)>,
) -> (String, tokio::task::JoinHandle<()>) {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/jellyfin", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
for (path, status, body) in responses {
let (mut socket, _) = tokio::time::timeout(Duration::from_secs(10), listener.accept())
.await
.unwrap()
.unwrap();
let mut bytes = Vec::new();
loop {
let mut buf = [0; 4096];
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
bytes.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&bytes);
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end]
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length: ")
.and_then(|s| s.parse::<usize>().ok())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
break;
}
}
}
let request = String::from_utf8(bytes).unwrap();
assert_eq!(request.lines().next().unwrap(), path);
if path.starts_with("POST") {
assert!(request.contains("DeviceId=\"stable-install\""));
}
if path.contains("AuthenticateWithQuickConnect") {
assert!(request.contains(r#"{"Secret":"private-secret"}"#));
assert!(!request.contains("123456"));
}
socket.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
}
});
(url, task)
}
const ENABLED: (&str, u16, &str) = ("GET /jellyfin/QuickConnect/Enabled HTTP/1.1", 200, "true");
const INIT: (&str, u16, &str) = (
"POST /jellyfin/QuickConnect/Initiate HTTP/1.1",
200,
r#"{"Authenticated":false,"Secret":"private-secret","Code":"123456"}"#,
);
const POLL: &str = "GET /jellyfin/QuickConnect/Connect?secret=private-secret HTTP/1.1";
#[tokio::test]
async fn pairing_pending_then_authorized_exchanges_secret_and_preserves_identity() {
let (url, task) = server(vec![
ENABLED,
INIT,
(POLL, 200, r#"{"Authenticated":false}"#),
(POLL, 200, r#"{"Authenticated":true}"#),
(
"POST /jellyfin/Users/AuthenticateWithQuickConnect HTTP/1.1",
200,
r#"{"AccessToken":"token","ServerId":"server","User":{"Id":"user","Name":"name"}}"#,
),
])
.await;
let pairing = QuickConnect::start(&url, "stable-install").await.unwrap();
assert_eq!(pairing.code(), "123456");
assert!(!pairing.authorized().await.unwrap());
let session = pairing
.wait_for_authorization(Duration::from_secs(9))
.await
.unwrap();
assert_eq!(session.access_token, "token");
assert_eq!(session.user_id, "user");
task.await.unwrap();
}
#[tokio::test]
async fn disabled_does_not_initiate() {
let (url, task) = server(vec![(ENABLED.0, 200, "false")]).await;
assert!(matches!(
QuickConnect::start(&url, "stable-install").await,
Err(JellyfinError::QuickConnectDisabled)
));
task.await.unwrap();
}
#[tokio::test]
async fn expiry_and_disabled_while_waiting_are_distinct() {
for status in [401, 404] {
let (url, task) = server(vec![ENABLED, INIT, (POLL, status, "null")]).await;
let pairing = QuickConnect::start(&url, "stable-install").await.unwrap();
let err = pairing.authorized().await.unwrap_err();
assert_eq!(
err,
if status == 401 {
JellyfinError::QuickConnectDisabled
} else {
JellyfinError::QuickConnectExpired
}
);
task.await.unwrap();
}
}
#[tokio::test]
async fn local_deadline_stops_polling() {
let (url, task) = server(vec![ENABLED, INIT]).await;
let pairing = QuickConnect::start(&url, "stable-install").await.unwrap();
assert!(matches!(
pairing
.wait_for_authorization(Duration::from_millis(20))
.await,
Err(JellyfinError::QuickConnectExpired)
));
task.await.unwrap();
}
#[tokio::test]
async fn malformed_response_does_not_echo_secret() {
let (url, task) = server(vec![
ENABLED,
(INIT.0, 200, r#"{"Authenticated":"private-secret"}"#),
])
.await;
let error = match QuickConnect::start(&url, "stable-install").await {
Err(e) => e,
Ok(_) => panic!("malformed response accepted"),
};
assert!(!error.to_string().contains("private-secret"));
task.await.unwrap();
}
#[tokio::test]
async fn exchange_rejection_is_not_reported_as_disabled() {
let (url, task) = server(vec![
ENABLED,
INIT,
(
"POST /jellyfin/Users/AuthenticateWithQuickConnect HTTP/1.1",
403,
"null",
),
])
.await;
let pairing = QuickConnect::start(&url, "stable-install").await.unwrap();
assert!(matches!(
pairing.authenticate().await,
Err(JellyfinError::AuthenticationRejected)
));
task.await.unwrap();
}
@@ -78,7 +78,41 @@ Column {
readonly property string effectiveUser: readonly property string effectiveUser:
root.userInput !== "" ? root.userInput : (root.state.username || "") root.userInput !== "" ? root.userInput : (root.state.username || "")
readonly property bool canConnect: readonly property bool canConnect:
root.effectiveUrl !== "" && root.effectiveUser !== "" && root.passInput !== "" root.effectiveUrl.trim() !== "" && root.effectiveUser.trim() !== "" && root.passInput !== ""
readonly property var operation: (JSON.parse(QbzLocal.mediaStatus || "{}"))[root.server] || ({})
readonly property bool busy: operation.busy === true || operation.syncing === true
readonly property bool pairing: root.server === "jellyfin" && root.busy
&& (operation.phase || "").indexOf("pairing") === 0
function cancelPairing() {
if (root.server === "jellyfin") QbzLocal.mediaCancelQuickConnect()
}
onVisibleChanged: { if (!visible) cancelPairing() }
Component.onDestruction: cancelPairing()
readonly property string statusText: {
// Each panel observes its own provider, including when both sync at once.
if (operation.syncing === true)
return QbzSession.tr("Connected. Syncing library…", QbzSession.trRev)
switch (operation.phase || "") {
case "pairing-start": return QbzSession.tr("Requesting Quick Connect code…", QbzSession.trRev)
case "pairing-waiting": return QbzSession.tr("Waiting for authorization…", QbzSession.trRev)
case "pairing-verifying": return QbzSession.tr("Checking library access…", QbzSession.trRev)
case "testing": return QbzSession.tr("Checking server…", QbzSession.trRev)
case "authenticating": return QbzSession.tr("Signing in…", QbzSession.trRev)
case "verifying": return QbzSession.tr("Checking library access…", QbzSession.trRev)
case "reachable": return root.server === "jellyfin"
? QbzSession.tr("Server reachable. Sign in to connect.", QbzSession.trRev)
: QbzSession.tr("Server reachable. Credentials have not been checked.", QbzSession.trRev)
case "syncing": return QbzSession.tr("Connected. Syncing library…", QbzSession.trRev)
case "ready": return QbzSession.tr("Connected. Library is up to date.", QbzSession.trRev)
case "connected": return QbzSession.tr("Connected", QbzSession.trRev)
case "refreshing": return QbzSession.tr("Connected. The server is refreshing its library.", QbzSession.trRev)
case "failed": case "sync-failed": return operation.error || ""
default: return root.state.hasCredential === true
? QbzSession.tr("Saved connection. Sync to check access.", QbzSession.trRev)
: QbzSession.tr("Sign in to sync this server's library.", QbzSession.trRev)
}
}
QbzTheme { id: theme } QbzTheme { id: theme }
@@ -186,6 +220,7 @@ Column {
// =============================== body ================================= // =============================== body =================================
Column { Column {
visible: root.state.enabled === true && root.state.collapsed !== true visible: root.state.enabled === true && root.state.collapsed !== true
onVisibleChanged: { if (!visible) root.cancelPairing() }
width: parent.width width: parent.width
spacing: 4 spacing: 4
@@ -197,6 +232,7 @@ Column {
Row { Row {
spacing: 8 spacing: 8
QbzLineEdit { kioskHost: root.kioskHost; QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
width: 240 width: 240
text: root.state.serverUrl || "" text: root.state.serverUrl || ""
placeholder: root.urlPlaceholder placeholder: root.urlPlaceholder
@@ -207,7 +243,7 @@ Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Test", QbzSession.trRev) label: QbzSession.tr("Test", QbzSession.trRev)
hasIcon: false hasIcon: false
btnEnabled: root.effectiveUrl !== "" btnEnabled: root.effectiveUrl.trim() !== "" && !root.busy
onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl) onClicked: QbzLocal.mediaTest(root.server, root.effectiveUrl)
} }
} }
@@ -216,6 +252,7 @@ Column {
SettingRow { kioskHost: root.kioskHost; SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Username", QbzSession.trRev) label: QbzSession.tr("Username", QbzSession.trRev)
QbzLineEdit { kioskHost: root.kioskHost; QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
width: 240 width: 240
text: root.state.username || "" text: root.state.username || ""
placeholder: QbzSession.tr("Account name", QbzSession.trRev) placeholder: QbzSession.tr("Account name", QbzSession.trRev)
@@ -228,6 +265,7 @@ Column {
label: QbzSession.tr("Password", QbzSession.trRev) label: QbzSession.tr("Password", QbzSession.trRev)
description: root.credentialNote description: root.credentialNote
QbzLineEdit { kioskHost: root.kioskHost; QbzLineEdit { kioskHost: root.kioskHost;
enabled: !root.busy
width: 240 width: 240
// NEVER prefilled: the document does not carry it, and asking // NEVER prefilled: the document does not carry it, and asking
// for it again is the honest cost of not shipping it to QML. // for it again is the honest cost of not shipping it to QML.
@@ -244,18 +282,49 @@ Column {
// --- Connect ------------------------------------------------------ // --- Connect ------------------------------------------------------
SettingRow { kioskHost: root.kioskHost; SettingRow { kioskHost: root.kioskHost;
label: QbzSession.tr("Connection", QbzSession.trRev) label: QbzSession.tr("Connection", QbzSession.trRev)
description: root.state.hasCredential === true description: root.statusText
? QbzSession.tr("Connected. Type a password to reconnect.", QbzSession.trRev)
: QbzSession.tr("Sign in to sync this server's library.", QbzSession.trRev)
QbzPrimaryButton { QbzPrimaryButton {
label: QbzSession.tr("Connect", QbzSession.trRev) label: QbzSession.tr("Connect", QbzSession.trRev)
btnEnabled: root.canConnect && !QbzLocal.mediaSyncing btnEnabled: root.canConnect && !root.busy
btnHeight: 34 btnHeight: 34
onClicked: QbzLocal.mediaConnect( onClicked: QbzLocal.mediaConnect(
root.server, root.effectiveUrl, root.effectiveUser, root.passInput) root.server, root.effectiveUrl, root.effectiveUser, root.passInput)
} }
} }
SettingRow { kioskHost: root.kioskHost;
id: quickConnectRow
objectName: "quickConnectRow"
visible: root.server === "jellyfin"
label: QbzSession.tr("Quick Connect", QbzSession.trRev)
description: QbzSession.tr("Enter this code in Settings > Quick Connect in a Jellyfin client that is already signed in. QBZ waits up to 5 minutes.", QbzSession.trRev)
Row {
spacing: 12
Text {
objectName: "quickConnectCode"
anchors.verticalCenter: parent.verticalCenter
visible: root.pairing && (root.operation.pairing_code || "") !== ""
text: root.operation.pairing_code || ""
color: theme.textPrimary
font.pixelSize: root.kioskHost ? 28 : 24
font.weight: Font.DemiBold
Accessible.name: text
}
IconTextButton {
objectName: "quickConnectButton"
anchors.verticalCenter: parent.verticalCenter
label: root.pairing ? QbzSession.tr("Cancel", QbzSession.trRev)
: QbzSession.tr("Quick Connect", QbzSession.trRev)
hasIcon: false
btnEnabled: root.pairing || (!root.busy && root.effectiveUrl.trim() !== "")
onClicked: {
if (root.pairing) root.cancelPairing()
else QbzLocal.mediaQuickConnect(root.effectiveUrl)
}
}
}
}
// --- Library sweep ------------------------------------------------ // --- Library sweep ------------------------------------------------
SettingRow { kioskHost: root.kioskHost; SettingRow { kioskHost: root.kioskHost;
visible: root.state.hasCredential === true visible: root.state.hasCredential === true
@@ -271,28 +340,30 @@ Column {
// The progress text replaces the buttons while a sweep runs — // The progress text replaces the buttons while a sweep runs —
// one control, one state, and no button to click twice. // one control, one state, and no button to click twice.
Text { Text {
visible: QbzLocal.mediaSyncing visible: root.operation.syncing === true
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: QbzLocal.mediaSyncProgress !== "" text: (root.operation.progress || "") !== ""
? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + QbzLocal.mediaSyncProgress ? QbzSession.tr("Syncing…", QbzSession.trRev) + " " + (root.operation.progress || "")
: QbzSession.tr("Syncing…", QbzSession.trRev) : QbzSession.tr("Syncing…", QbzSession.trRev)
color: theme.textSecondary color: theme.textSecondary
font.pixelSize: root.kioskHost ? (12) * 1.2 : (12) font.pixelSize: root.kioskHost ? (12) * 1.2 : (12)
} }
IconTextButton { IconTextButton {
visible: !QbzLocal.mediaSyncing visible: root.operation.syncing !== true
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Sync now", QbzSession.trRev) label: QbzSession.tr("Sync now", QbzSession.trRev)
hasIcon: false hasIcon: false
btnEnabled: !root.busy
onClicked: QbzLocal.mediaSync(root.server, false) onClicked: QbzLocal.mediaSync(root.server, false)
} }
// FULL is separate because it is the expensive one and the // FULL is separate because it is the expensive one and the
// answer to "the delta missed something", not the default. // answer to "the delta missed something", not the default.
IconTextButton { IconTextButton {
visible: !QbzLocal.mediaSyncing && root.state.lastSyncAt > 0 visible: root.operation.syncing !== true && root.state.lastSyncAt > 0
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Full re-sync", QbzSession.trRev) label: QbzSession.tr("Full re-sync", QbzSession.trRev)
hasIcon: false hasIcon: false
btnEnabled: !root.busy
onClicked: QbzLocal.mediaSync(root.server, true) onClicked: QbzLocal.mediaSync(root.server, true)
} }
} }
+177 -34
View File
@@ -229,6 +229,7 @@ pub mod qbz_local {
/// mirrors `ui_prefs.json`, so one property buys BOTH kinds of /// mirrors `ui_prefs.json`, so one property buys BOTH kinds of
/// persistence the owner asked for. /// persistence the owner asked for.
#[qproperty(QString, albums_filter)] #[qproperty(QString, albums_filter)]
#[qproperty(QString, media_status)]
#[qproperty(bool, media_syncing)] #[qproperty(bool, media_syncing)]
#[qproperty(QString, media_sync_progress)] #[qproperty(QString, media_sync_progress)]
/// Tracks written by the last sync; -1 = never synced this session. /// Tracks written by the last sync; -1 = never synced this session.
@@ -599,6 +600,10 @@ pub mod qbz_local {
username: QString, username: QString,
password: QString, password: QString,
); );
#[qinvokable]
fn media_quick_connect(self: Pin<&mut QbzLocal>, url: QString);
#[qinvokable]
fn media_cancel_quick_connect(self: Pin<&mut QbzLocal>);
/// Master toggle. OFF collapses the union back immediately; the cache /// Master toggle. OFF collapses the union back immediately; the cache
/// is KEPT so turning it on again does not re-sweep. /// is KEPT so turning it on again does not re-sweep.
#[qinvokable] #[qinvokable]
@@ -783,6 +788,7 @@ pub struct QbzLocalRust {
media_has_jellyfin: bool, media_has_jellyfin: bool,
media_has_subsonic: bool, media_has_subsonic: bool,
albums_filter: QString, albums_filter: QString,
media_status: QString,
media_syncing: bool, media_syncing: bool,
media_sync_progress: QString, media_sync_progress: QString,
plex_last_sync_tracks: i32, plex_last_sync_tracks: i32,
@@ -862,6 +868,7 @@ impl Default for QbzLocalRust {
media_has_jellyfin: false, media_has_jellyfin: false,
media_has_subsonic: false, media_has_subsonic: false,
albums_filter: QString::default(), albums_filter: QString::default(),
media_status: QString::from("{}"),
media_syncing: false, media_syncing: false,
media_sync_progress: QString::default(), media_sync_progress: QString::default(),
plex_last_sync_tracks: -1, plex_last_sync_tracks: -1,
@@ -1319,13 +1326,82 @@ impl qbz_local::QbzLocal {
} }
pub fn media_test(self: Pin<&mut Self>, server: QString, url: QString) { pub fn media_test(self: Pin<&mut Self>, server: QString, url: QString) {
let (Some(kind), url) = (media_kind(&server), url.to_string()) else { let Some(kind) = media_kind(&server) else {
return;
};
let url = url.to_string();
if url.trim().is_empty() {
return;
}
let _ = crate::media_servers_qt::get(kind);
let Some(operation) = crate::media_connection_qt::Operation::begin(kind, "testing") else {
return; return;
}; };
crate::spawn(async move { crate::spawn(async move {
match crate::media_servers_qt::probe(kind, &url).await { let result = operation
Ok(name) => crate::toast_qt::success(format!("Connected to {name}")), .request(
Err(e) => crate::toast_qt::error(e), crate::media_servers_qt::probe(kind, url.trim()),
std::time::Duration::from_secs(15),
)
.await;
match result {
Some(Ok(_)) => operation.phase("reachable", ""),
Some(Err(error)) => {
log::warn!(
"[media-connection] provider={} phase=testing failed: {error}",
kind.as_str()
);
operation.phase("failed", &error);
crate::toast_qt::error(error);
}
None => {}
}
});
}
pub fn media_cancel_quick_connect(self: Pin<&mut Self>) {
crate::media_connection_qt::cancel_pairing();
}
pub fn media_quick_connect(self: Pin<&mut Self>, url: QString) {
let kind = qbz_app::settings::media_servers::MediaServerKind::Jellyfin;
let url = url.to_string();
if url.trim().is_empty() {
return;
}
let cfg = crate::media_servers_qt::get(kind);
let Some(operation) = crate::media_connection_qt::Operation::begin(kind, "pairing-start")
else {
return;
};
crate::spawn(async move {
let result = operation
.request(
crate::media_servers_qt::quick_connect(url.trim(), cfg, &operation),
std::time::Duration::from_secs(350),
)
.await;
match result {
Some(Ok(cfg)) => {
if operation
.commit(|| crate::media_servers_qt::put(kind, &cfg))
.is_none()
{
return;
}
operation.phase("connected", "");
crate::settings_qt::publish_snapshot().await;
if operation.current() {
run_media_sync(kind, true, &operation).await;
}
}
Some(Err(error)) => {
log::warn!(
"[media-connection] provider=jellyfin phase=quick-connect failed: {error}"
);
operation.phase("failed", &error);
crate::toast_qt::error(error);
}
None => {}
} }
}); });
} }
@@ -1341,14 +1417,51 @@ impl qbz_local::QbzLocal {
return; return;
}; };
let (url, user, pass) = (url.to_string(), username.to_string(), password.to_string()); let (url, user, pass) = (url.to_string(), username.to_string(), password.to_string());
if url.trim().is_empty() || user.trim().is_empty() || pass.is_empty() {
return;
}
let cfg = crate::media_servers_qt::get(kind);
let Some(operation) = crate::media_connection_qt::Operation::begin(kind, "authenticating")
else {
return;
};
crate::spawn(async move { crate::spawn(async move {
match crate::media_servers_qt::connect(kind, &url, &user, &pass).await { let result = operation
Ok(()) => { .request(
crate::toast_qt::success(qbz_i18n::t("Connected")); crate::media_servers_qt::connect(
kind,
url.trim(),
user.trim(),
&pass,
cfg,
&operation,
),
std::time::Duration::from_secs(35),
)
.await;
match result {
Some(Ok(cfg)) => {
if operation
.commit(|| crate::media_servers_qt::put(kind, &cfg))
.is_none()
{
return;
}
operation.phase("connected", "");
crate::settings_qt::publish_snapshot().await; crate::settings_qt::publish_snapshot().await;
run_media_sync(kind, true).await; if operation.current() {
run_media_sync(kind, true, &operation).await;
}
} }
Err(e) => crate::toast_qt::error(e), Some(Err(error)) => {
log::warn!(
"[media-connection] provider={} phase=connect failed: {error}",
kind.as_str()
);
operation.phase("failed", &error);
crate::toast_qt::error(error);
}
None => {}
} }
}); });
} }
@@ -1357,17 +1470,17 @@ impl qbz_local::QbzLocal {
let Some(kind) = media_kind(&server) else { let Some(kind) = media_kind(&server) else {
return; return;
}; };
// Invalidate pending authentication before changing the stored switch.
// A late response must not silently re-enable a disabled integration.
crate::media_connection_qt::cancel(kind);
if !enabled {
crate::media_sync_qt::cancel(kind);
}
let mut cfg = crate::media_servers_qt::get(kind);
cfg.enabled = enabled;
crate::media_servers_qt::put(kind, &cfg);
crate::spawn(async move { crate::spawn(async move {
let mut cfg = crate::media_servers_qt::get(kind);
cfg.enabled = enabled;
crate::media_servers_qt::put(kind, &cfg);
// The panel reads `state.enabled` off the SETTINGS DOCUMENT, not
// off a bridge property the way Plex's toggle does — so a write
// that does not republish leaves the switch visually stuck in its
// old position while the store underneath has already changed
// (caught by driving the real window, 2026-08-20).
crate::settings_qt::publish_snapshot().await; crate::settings_qt::publish_snapshot().await;
// The union IS the query — the grid/tracks/badges must re-run.
reload_browse(); reload_browse();
}); });
} }
@@ -1376,29 +1489,38 @@ impl qbz_local::QbzLocal {
let Some(kind) = media_kind(&server) else { let Some(kind) = media_kind(&server) else {
return; return;
}; };
// Refuse HERE rather than letting the sweep's own guard reject it: a let Some(operation) = crate::media_connection_qt::Operation::begin(kind, "syncing") else {
// second click should say why, and by the time the guard sees it the
// caller has already been told the task started.
if crate::media_sync_qt::is_syncing(kind) {
crate::toast_qt::info(qbz_i18n::t("A sync is already running"));
return; return;
} };
crate::spawn(async move { run_media_sync(kind, full).await }); crate::spawn(async move {
run_media_sync(kind, full, &operation).await;
});
} }
pub fn media_disconnect(self: Pin<&mut Self>, server: QString) { pub fn media_disconnect(self: Pin<&mut Self>, server: QString) {
let Some(kind) = media_kind(&server) else { let Some(kind) = media_kind(&server) else {
return; return;
}; };
crate::media_connection_qt::cancel(kind);
// Invalidate credentials before another event can start a connection.
crate::media_servers_qt::disconnect(kind);
let user = qbz_app::user_data::UserDataPaths::load_last_user_id();
crate::spawn(async move { crate::spawn(async move {
crate::media_servers_qt::disconnect(kind);
crate::settings_qt::publish_snapshot().await; crate::settings_qt::publish_snapshot().await;
// Purge the rows too: leaving them would keep a signed-out // A large cache purge must not block the UI. The state gate also
// server's music in the grid until something else cleared it, // protects against a profile rebind while this job owns the cache.
// and the master-toggle path deliberately does NOT purge (so it let _ = tokio::task::spawn_blocking(move || {
// can be undone cheaply). Disconnect is the destructive one. let _guard = crate::media_sync_qt::media_server_state_guard(kind);
crate::media_servers_qt::purge_cache(kind); if qbz_app::user_data::UserDataPaths::load_last_user_id() == user
reload_browse(); && !crate::media_servers_qt::get(kind).is_configured(kind)
{
crate::media_servers_qt::purge_cache(kind);
}
})
.await;
if qbz_app::user_data::UserDataPaths::load_last_user_id() == user {
reload_browse();
}
}); });
} }
@@ -2114,7 +2236,15 @@ fn media_kind(server: &QString) -> Option<qbz_app::settings::media_servers::Medi
/// Run a sweep and report it, then reload the browse documents so the new rows /// Run a sweep and report it, then reload the browse documents so the new rows
/// appear without the user navigating away and back. /// appear without the user navigating away and back.
async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind, full: bool) { async fn run_media_sync(
kind: qbz_app::settings::media_servers::MediaServerKind,
full: bool,
operation: &crate::media_connection_qt::Operation,
) {
if !operation.current() {
return;
}
operation.phase("syncing", "");
use qbz_app::settings::media_servers::MediaServerKind; use qbz_app::settings::media_servers::MediaServerKind;
let result = match kind { let result = match kind {
MediaServerKind::Jellyfin => crate::media_sync_qt::sync_jellyfin(full) MediaServerKind::Jellyfin => crate::media_sync_qt::sync_jellyfin(full)
@@ -2122,8 +2252,12 @@ async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind,
.map_err(crate::media_sync_qt::SyncError::Failed), .map_err(crate::media_sync_qt::SyncError::Failed),
MediaServerKind::Subsonic => crate::media_sync_qt::sync_subsonic(full).await, MediaServerKind::Subsonic => crate::media_sync_qt::sync_subsonic(full).await,
}; };
if !operation.current() {
return;
}
match result { match result {
Ok(r) => { Ok(r) => {
operation.phase("ready", "");
log::info!( log::info!(
"[qbz-qt] {} sync: {} saved, {} pruned, {} cached", "[qbz-qt] {} sync: {} saved, {} pruned, {} cached",
kind.as_str(), kind.as_str(),
@@ -2137,11 +2271,20 @@ async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind,
reload_browse(); reload_browse();
} }
Err(crate::media_sync_qt::SyncError::ServerRefreshing { server, scanned }) => { Err(crate::media_sync_qt::SyncError::ServerRefreshing { server, scanned }) => {
operation.phase("refreshing", "");
crate::toast_qt::info(qbz_i18n::t_args( crate::toast_qt::info(qbz_i18n::t_args(
"{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again.", "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again.",
&[&server, &scanned.to_string()], &[&server, &scanned.to_string()],
)); ));
} }
Err(crate::media_sync_qt::SyncError::Failed(error)) => crate::toast_qt::error(error), Err(crate::media_sync_qt::SyncError::Failed(error)) => {
let message = qbz_i18n::t_args("Library sync failed: {}", &[&error]);
log::warn!(
"[media-connection] provider={} phase=syncing failed: {error}",
kind.as_str()
);
operation.phase("sync-failed", &message);
crate::toast_qt::error(message);
}
} }
} }
+1
View File
@@ -152,6 +152,7 @@ mod source_wiring;
// Jellyfin / Subsonic: the per-user settings store plus the two gates the // Jellyfin / Subsonic: the per-user settings store plus the two gates the
// Local Library union reads. The credential glue itself is in `source_wiring`. // Local Library union reads. The credential glue itself is in `source_wiring`.
mod media_servers_qt; mod media_servers_qt;
mod media_connection_qt;
// Profile-scoped metadata overlays for Plex/Jellyfin/Subsonic. Physical files // Profile-scoped metadata overlays for Plex/Jellyfin/Subsonic. Physical files
// keep using `.qbz.json`; this module is the read-through cache for servers // keep using `.qbz.json`; this module is the read-through cache for servers
// that have no local directory to put that document beside. // that have no local directory to put that document beside.
+120
View File
@@ -0,0 +1,120 @@
//! Qt publication adapter for the shared media-server operation gate.
use qbz_app::settings::{
media_connection::{Attempt, ConnectionGate},
media_servers::MediaServerKind,
};
use std::sync::LazyLock;
use std::time::Duration;
static GATE: LazyLock<ConnectionGate> = LazyLock::new(ConnectionGate::default);
pub fn publish() {
crate::local_bridge::ui(|mut b| {
// Read at delivery time: an old queued callback cannot restore a stale status.
let jellyfin = GATE.status(MediaServerKind::Jellyfin);
let subsonic = GATE.status(MediaServerKind::Subsonic);
b.as_mut()
.set_media_syncing(jellyfin.syncing || subsonic.syncing);
let state = serde_json::json!({ "jellyfin": jellyfin, "subsonic": subsonic });
b.as_mut()
.set_media_status(cxx_qt_lib::QString::from(state.to_string().as_str()));
});
}
pub struct Operation(Option<Attempt>);
impl Operation {
#[cfg(test)]
pub fn isolated(kind: MediaServerKind) -> Self {
Self(Some(
ConnectionGate::default()
.begin(kind, "authenticating")
.unwrap(),
))
}
pub fn begin(kind: MediaServerKind, phase: &str) -> Option<Self> {
if crate::media_sync_qt::is_syncing(kind) {
return None;
}
let op = Self(Some(GATE.begin(kind, phase)?));
log::info!(
"[media-connection] provider={} phase={phase}",
kind.as_str()
);
publish();
Some(op)
}
fn attempt(&self) -> &Attempt {
self.0.as_ref().expect("active operation")
}
pub fn current(&self) -> bool {
self.attempt().current()
}
pub fn commit<T>(&self, write: impl FnOnce() -> T) -> Option<T> {
self.attempt().commit(write)
}
pub fn phase(&self, phase: &str, error: &str) {
if !self.current() {
return;
}
log::info!(
"[media-connection] provider={} phase={phase}",
self.attempt().kind().as_str()
);
self.attempt().phase(phase, error);
publish();
}
pub fn pairing_code(&self, code: &str) {
self.attempt().pairing_code(code);
publish();
}
pub async fn request<T>(
&self,
future: impl std::future::Future<Output = Result<T, String>>,
timeout: Duration,
) -> Option<Result<T, String>> {
use qbz_app::settings::media_connection::RequestOutcome;
match self.attempt().run(future, timeout).await {
RequestOutcome::Completed(result) => Some(result),
RequestOutcome::Cancelled => None,
RequestOutcome::TimedOut => Some(Err(qbz_i18n::t(
"Connection timed out. Check the server address and try again.",
))),
}
}
}
impl Drop for Operation {
fn drop(&mut self) {
drop(self.0.take());
publish();
}
}
pub fn cancel(kind: MediaServerKind) {
GATE.cancel(kind);
publish();
}
pub fn cancel_all() {
for kind in MediaServerKind::ALL {
cancel(kind);
}
}
pub fn syncing(kind: MediaServerKind, active: bool) {
GATE.sync(kind, active);
publish();
}
pub fn progress(kind: MediaServerKind, value: String) {
GATE.progress(kind, value);
publish();
}
pub fn begin_sync(kind: MediaServerKind) -> bool {
let acquired = GATE.begin_sync(kind);
if acquired {
publish();
}
acquired
}
pub fn cancel_pairing() {
GATE.cancel_pairing(MediaServerKind::Jellyfin);
publish();
}
+205 -17
View File
@@ -58,6 +58,7 @@ fn ensure_bound() {
if *bound == Some(uid) { if *bound == Some(uid) {
return; return;
} }
crate::media_connection_qt::cancel_all();
invalidate_cache(); invalidate_cache();
STATE.reset(); STATE.reset();
let dir = dirs::data_dir() let dir = dirs::data_dir()
@@ -72,6 +73,7 @@ fn ensure_bound() {
/// Bind the store to the active user. Called from `auth_qt`. /// Bind the store to the active user. Called from `auth_qt`.
pub fn init_for_user(base_dir: &std::path::Path) { pub fn init_for_user(base_dir: &std::path::Path) {
crate::media_connection_qt::cancel_all();
invalidate_cache(); invalidate_cache();
STATE.init_at(base_dir); STATE.init_at(base_dir);
*BOUND.lock().unwrap_or_else(|error| error.into_inner()) = *BOUND.lock().unwrap_or_else(|error| error.into_inner()) =
@@ -84,6 +86,7 @@ pub fn init_for_user(base_dir: &std::path::Path) {
} }
pub fn reset() { pub fn reset() {
crate::media_connection_qt::cancel_all();
invalidate_cache(); invalidate_cache();
*BOUND.lock().unwrap_or_else(|error| error.into_inner()) = None; *BOUND.lock().unwrap_or_else(|error| error.into_inner()) = None;
STATE.reset(); STATE.reset();
@@ -271,18 +274,80 @@ pub async fn probe(kind: MediaServerKind, url: &str) -> Result<String, String> {
} }
} }
/// Authenticate and persist. /// Authenticate and verify library access; return settings for a guarded commit.
/// ///
/// What gets stored differs per protocol, and the asymmetry is the protocol's: /// What gets stored differs per protocol, and the asymmetry is the protocol's:
/// Jellyfin issues a token and never needs the password again; Subsonic has no /// Jellyfin issues a token and never needs the password again; Subsonic has no
/// session, so the password is kept and its token is re-derived per request. /// session, so the password is kept and its token is re-derived per request.
async fn verified_jellyfin_session(
url: &str,
session: qbz_jellyfin::Session,
mut cfg: MediaServerSettings,
) -> Result<MediaServerSettings, String> {
qbz_jellyfin::JellyfinClient::new(url, &session.access_token, &session.user_id)
.map_err(|e| e.to_string())?
.music_libraries()
.await
.map_err(|e| {
qbz_i18n::t_args(
"Signed in, but library access failed: {}",
&[&e.to_string()],
)
})?;
let info = tokio::time::timeout(std::time::Duration::from_secs(3), qbz_jellyfin::probe(url))
.await
.ok()
.and_then(Result::ok);
cfg.base_url = qbz_jellyfin::normalize_base_url(url);
cfg.token = session.access_token;
// The USER ID, not the typed name: every `/Items` call keys on it,
// and the two are not interchangeable.
cfg.username = session.user_id;
cfg.server_id = session.server_id;
cfg.server_name = info.map(|i| i.server_name).unwrap_or_default();
// The password is deliberately NOT stored — the token replaced it.
cfg.password = String::new();
cfg.enabled = true;
Ok(cfg)
}
pub async fn quick_connect(
url: &str,
cfg: MediaServerSettings,
operation: &crate::media_connection_qt::Operation,
) -> Result<MediaServerSettings, String> {
let pairing = qbz_jellyfin::QuickConnect::start(url, &cfg.device_id)
.await
.map_err(quick_connect_error)?;
operation.phase("pairing-waiting", "");
operation.pairing_code(pairing.code());
let session = pairing
.wait_for_authorization(std::time::Duration::from_secs(300))
.await
.map_err(quick_connect_error)?;
operation.phase("pairing-verifying", "");
verified_jellyfin_session(url, session, cfg).await
}
fn quick_connect_error(error: qbz_jellyfin::JellyfinError) -> String {
match error {
qbz_jellyfin::JellyfinError::QuickConnectDisabled => {
qbz_i18n::t("Quick Connect is disabled on this server.")
}
qbz_jellyfin::JellyfinError::QuickConnectExpired => {
qbz_i18n::t("Quick Connect expired. Request a new code.")
}
other => qbz_i18n::t_args("Quick Connect failed: {}", &[&other.to_string()]),
}
}
pub async fn connect( pub async fn connect(
kind: MediaServerKind, kind: MediaServerKind,
url: &str, url: &str,
username: &str, username: &str,
password: &str, password: &str,
) -> Result<(), String> { mut cfg: MediaServerSettings,
let mut cfg = get(kind); operation: &crate::media_connection_qt::Operation,
) -> Result<MediaServerSettings, String> {
match kind { match kind {
MediaServerKind::Jellyfin => { MediaServerKind::Jellyfin => {
// The DeviceId must be the PERSISTED one: authenticating under a // The DeviceId must be the PERSISTED one: authenticating under a
@@ -290,23 +355,25 @@ pub async fn connect(
// server's device list. `init_for_user` minted it. // server's device list. `init_for_user` minted it.
let session = qbz_jellyfin::authenticate(url, &cfg.device_id, username, password) let session = qbz_jellyfin::authenticate(url, &cfg.device_id, username, password)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| qbz_i18n::t_args("Sign-in failed: {}", &[&e.to_string()]))?;
let info = qbz_jellyfin::probe(url).await.ok(); operation.phase("verifying", "");
cfg.base_url = qbz_jellyfin::normalize_base_url(url); return verified_jellyfin_session(url, session, cfg).await;
cfg.token = session.access_token;
// The USER ID, not the typed name: every `/Items` call keys on it,
// and the two are not interchangeable.
cfg.username = session.user_id;
cfg.server_id = session.server_id;
cfg.server_name = info.map(|i| i.server_name).unwrap_or_default();
// The password is deliberately NOT stored — the token replaced it.
cfg.password = String::new();
} }
MediaServerKind::Subsonic => { MediaServerKind::Subsonic => {
let creds = qbz_subsonic::Credentials::new(username, password, &cfg.salt); let creds = qbz_subsonic::Credentials::new(username, password, &cfg.salt);
let client = let client =
qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?; qbz_subsonic::SubsonicClient::new(url, creds).map_err(|e| e.to_string())?;
let info = client.ping().await.map_err(|e| e.to_string())?; let info = client
.ping()
.await
.map_err(|e| qbz_i18n::t_args("Sign-in failed: {}", &[&e.to_string()]))?;
operation.phase("verifying", "");
client.album_ids(0).await.map_err(|e| {
qbz_i18n::t_args(
"Signed in, but library access failed: {}",
&[&e.to_string()],
)
})?;
if !info.open_subsonic { if !info.open_subsonic {
// Not fatal: the library still lists and still plays. But // Not fatal: the library still lists and still plays. But
// without the OpenSubsonic fields there is no bitDepth or // without the OpenSubsonic fields there is no bitDepth or
@@ -327,8 +394,7 @@ pub async fn connect(
} }
} }
cfg.enabled = true; cfg.enabled = true;
put(kind, &cfg); Ok(cfg)
Ok(())
} }
/// Drop this server's cached rows. /// Drop this server's cached rows.
@@ -663,6 +729,128 @@ pub fn search_tracks(query: &str, limit: Option<u32>) -> Vec<qbz_library::LocalT
mod tests { mod tests {
use super::*; use super::*;
// Exercise actual HTTP requests without touching configured servers or settings.
async fn fixture(
responses: Vec<(&'static str, u16, &'static str)>,
) -> (String, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
for (path, status, body) in responses {
let (mut socket, _) =
tokio::time::timeout(std::time::Duration::from_secs(5), listener.accept())
.await
.unwrap()
.unwrap();
let mut bytes = Vec::new();
loop {
let mut buf = [0; 2048];
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
bytes.extend_from_slice(&buf[..n]);
if let Some(end) = bytes.windows(4).position(|b| b == b"\r\n\r\n") {
let header = String::from_utf8_lossy(&bytes[..end]);
let length = header
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
break;
}
}
}
let request = String::from_utf8_lossy(&bytes);
assert!(request.lines().next().unwrap().contains(path));
if path.contains("/Views") {
assert!(request
.to_ascii_lowercase()
.contains("x-emby-token: test-token"));
}
let response = format!("HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len());
socket.write_all(response.as_bytes()).await.unwrap();
}
});
(url, task)
}
#[tokio::test]
async fn connection_distinguishes_login_from_library_rejection_for_both_providers() {
qbz_app::ensure_crypto_provider();
const JF_AUTH: &str =
r#"{"AccessToken":"test-token","ServerId":"server","User":{"Id":"uid","Name":"user"}}"#;
const SUB_OK: &str = r#"{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","openSubsonic":true,"albumList2":{"album":[]}}}"#;
const SUB_NO: &str =
r#"{"subsonic-response":{"status":"failed","error":{"code":40,"message":"denied"}}}"#;
for kind in MediaServerKind::ALL {
for mode in ["login-rejected", "library-rejected", "success"] {
let responses = match (kind, mode) {
(MediaServerKind::Jellyfin, "login-rejected") => {
vec![("/Users/AuthenticateByName", 401, "{}")]
}
(MediaServerKind::Jellyfin, "library-rejected") => vec![
("/Users/AuthenticateByName", 200, JF_AUTH),
("/Users/uid/Views", 401, "{}"),
],
(MediaServerKind::Jellyfin, _) => vec![
("/Users/AuthenticateByName", 200, JF_AUTH),
(
"/Users/uid/Views",
200,
r#"{"Items":[],"TotalRecordCount":0}"#,
),
(
"/System/Info/Public",
200,
r#"{"ServerName":"test","Version":"10.11","Id":"server"}"#,
),
],
(MediaServerKind::Subsonic, "login-rejected") => {
vec![("/rest/ping.view", 200, SUB_NO)]
}
(MediaServerKind::Subsonic, "library-rejected") => vec![
("/rest/ping.view", 200, SUB_OK),
("/rest/getAlbumList2.view", 200, SUB_NO),
],
(MediaServerKind::Subsonic, _) => vec![
("/rest/ping.view", 200, SUB_OK),
("/rest/getAlbumList2.view", 200, SUB_OK),
],
};
let (url, server) = fixture(responses).await;
let operation = crate::media_connection_qt::Operation::isolated(kind);
let cfg = MediaServerSettings {
device_id: "test-device".into(),
salt: "test-salt".into(),
..Default::default()
};
let result = connect(kind, &url, "user", "password", cfg, &operation).await;
server.await.unwrap();
match mode {
"login-rejected" => assert!(result.unwrap_err().contains("Sign-in failed")),
"library-rejected" => assert!(result
.unwrap_err()
.contains("Signed in, but library access failed")),
_ => {
let cfg = result.unwrap();
assert!(cfg.enabled);
if kind == MediaServerKind::Jellyfin {
assert_eq!(cfg.token, "test-token");
assert!(cfg.password.is_empty());
} else {
assert_eq!(cfg.password, "password");
}
}
}
drop(operation);
}
}
}
/// The identifiers must be unique per install. They are generated once and /// The identifiers must be unique per install. They are generated once and
/// persisted, so the only property worth pinning here is that two draws do /// persisted, so the only property worth pinning here is that two draws do
/// not collide — a fixed string would silently make every install share a /// not collide — a fixed string would silently make every install share a
+7 -25
View File
@@ -197,20 +197,24 @@ fn invalidate_jellyfin_quality() {
/// RAII guard so an early return — or a `?` — cannot leave the flag stuck on. /// RAII guard so an early return — or a `?` — cannot leave the flag stuck on.
/// A stuck flag means the sync button never works again until a restart. /// A stuck flag means the sync button never works again until a restart.
struct BusyGuard(&'static AtomicBool); struct BusyGuard(&'static AtomicBool, MediaServerKind);
impl BusyGuard { impl BusyGuard {
fn acquire(kind: MediaServerKind) -> Option<Self> { fn acquire(kind: MediaServerKind) -> Option<Self> {
let flag = busy_flag(kind); let flag = busy_flag(kind);
if flag.load(Ordering::Acquire) || !crate::media_connection_qt::begin_sync(kind) {
return None;
}
flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.ok() .ok()
.map(|_| BusyGuard(flag)) .map(|_| BusyGuard(flag, kind))
} }
} }
impl Drop for BusyGuard { impl Drop for BusyGuard {
fn drop(&mut self) { fn drop(&mut self) {
self.0.store(false, Ordering::Release); self.0.store(false, Ordering::Release);
crate::media_connection_qt::syncing(self.1, false);
} }
} }
@@ -254,25 +258,7 @@ impl std::fmt::Display for SyncError {
fn report(kind: MediaServerKind, done: u64, total: u64) { fn report(kind: MediaServerKind, done: u64, total: u64) {
log::info!("[qbz-qt] {} sync: {done}/{total}", kind.as_str()); log::info!("[qbz-qt] {} sync: {done}/{total}", kind.as_str());
let text = format!("{done}/{total}"); let text = format!("{done}/{total}");
crate::local_bridge::ui(move |mut b| { crate::media_connection_qt::progress(kind, text.clone());
b.as_mut()
.set_media_sync_progress(cxx_qt_lib::QString::from(text.as_str()));
});
}
/// Raise/lower the spinner flag and clear the progress text when it goes down.
///
/// Separate from [`BusyGuard`] on purpose: that guard is process state and must
/// be released synchronously on every path, while this hop crosses to the Qt
/// thread and cannot be done from a `Drop`.
fn set_syncing_ui(on: bool) {
crate::local_bridge::ui(move |mut b| {
b.as_mut().set_media_syncing(on);
if !on {
b.as_mut()
.set_media_sync_progress(cxx_qt_lib::QString::default());
}
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -299,9 +285,7 @@ pub async fn sync_jellyfin(full: bool) -> Result<SyncReport, String> {
if !cfg.is_configured(kind) { if !cfg.is_configured(kind) {
return Err("jellyfin is not configured".into()); return Err("jellyfin is not configured".into());
} }
set_syncing_ui(true);
let out = sync_jellyfin_inner(cfg, full).await; let out = sync_jellyfin_inner(cfg, full).await;
set_syncing_ui(false);
out out
} }
@@ -810,11 +794,9 @@ pub async fn sync_subsonic(_full: bool) -> Result<SyncReport, SyncError> {
} }
} }
let sync_epoch = SUBSONIC_EPOCH.fetch_add(1, Ordering::AcqRel) + 1; let sync_epoch = SUBSONIC_EPOCH.fetch_add(1, Ordering::AcqRel) + 1;
set_syncing_ui(true);
let out = sync_subsonic_inner(cfg, client, sync_epoch) let out = sync_subsonic_inner(cfg, client, sync_epoch)
.await .await
.map_err(SyncError::Failed); .map_err(SyncError::Failed);
set_syncing_ui(false);
out out
} }
+1
View File
@@ -549,6 +549,7 @@ pub fn normalize_base_url(input: &str) -> String {
fn client() -> Result<reqwest::Client> { fn client() -> Result<reqwest::Client> {
reqwest::Client::builder() reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(HTTP_TIMEOUT) .timeout(HTTP_TIMEOUT)
.build() .build()
.map_err(|e| transport_error(&e)) .map_err(|e| transport_error(&e))
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { function settingsBool(key, value) {} }
@@ -0,0 +1,8 @@
pragma Singleton
import QtQuick
QtObject {
property string mediaStatus: "{}"
property int pairingCancels: 0
function mediaCancelQuickConnect() { pairingCancels++ }
function mediaQuickConnect(url) {}
}
@@ -0,0 +1,3 @@
pragma Singleton
import QtQuick
QtObject { property int trRev: 0; function tr(text, rev) { return text } }
@@ -1,3 +1,6 @@
module com.blitzfc.qbz module com.blitzfc.qbz
singleton QbzShell 1.0 QbzShell.qml singleton QbzShell 1.0 QbzShell.qml
singleton QbzPlayer 1.0 QbzPlayer.qml singleton QbzPlayer 1.0 QbzPlayer.qml
singleton QbzLocal 1.0 QbzLocal.qml
singleton QbzSession 1.0 QbzSession.qml
singleton QbzBridge 1.0 QbzBridge.qml
+61
View File
@@ -0,0 +1,61 @@
import QtQuick
import QtTest
import com.blitzfc.qbz
import "../../crates/qbz-qt/qml/settings" as Settings
Item {
width: 1000; height: 1000
Settings.MediaServerSettings {
id: jellyfin; width: 500
server: "jellyfin"; title: "Jellyfin"; subtitle: ""
urlPlaceholder: ""; testHint: ""; credentialNote: ""; syncCost: ""
state: ({enabled:true,hasCredential:true,serverUrl:"http://localhost",username:"user"})
}
Settings.MediaServerSettings {
id: subsonic; x: 500; width: 500
server: "subsonic"; title: "Subsonic"; subtitle: ""
urlPlaceholder: ""; testHint: ""; credentialNote: ""; syncCost: ""
state: ({enabled:true,hasCredential:true,serverUrl:"http://localhost",username:"user"})
}
TestCase {
name: "MediaConnectionStatus"; when: windowShown
function init() { QbzLocal.mediaStatus="{}" }
function test_pairing_only_jellyfin_and_no_password_required() {
verify(findChild(jellyfin, "quickConnectRow").visible)
verify(!findChild(subsonic, "quickConnectRow").visible)
verify(!jellyfin.canConnect)
verify(findChild(jellyfin, "quickConnectButton").btnEnabled)
QbzLocal.mediaStatus=JSON.stringify({jellyfin:{busy:true,phase:"pairing-waiting",pairing_code:"123456"}})
verify(jellyfin.pairing)
verify(!subsonic.busy)
compare(findChild(jellyfin, "quickConnectCode").text,"123456")
compare(jellyfin.statusText,"Waiting for authorization…")
verify(findChild(jellyfin, "quickConnectButton").btnEnabled)
jellyfin.visible=false
verify(QbzLocal.pairingCancels > 0)
jellyfin.visible=true
}
function test_saved_does_not_claim_verified() {
compare(jellyfin.statusText,"Saved connection. Sync to check access.")
}
function test_busy_is_per_provider() {
QbzLocal.mediaStatus=JSON.stringify({jellyfin:{busy:true,phase:"authenticating"},subsonic:{busy:false}})
verify(jellyfin.busy); verify(!subsonic.busy)
compare(jellyfin.statusText,"Signing in…")
}
function test_sync_does_not_claim_other_provider_is_syncing() {
QbzLocal.mediaStatus=JSON.stringify({jellyfin:{syncing:true,progress:"50/100"},subsonic:{phase:"failed",error:"sign-in refused"}})
verify(jellyfin.busy); verify(!subsonic.busy)
compare(jellyfin.statusText,"Connected. Syncing library…")
compare(subsonic.statusText,"sign-in refused")
}
function test_both_can_progress_independently() {
QbzLocal.mediaStatus=JSON.stringify({jellyfin:{syncing:true,progress:"50/100"},subsonic:{syncing:true,progress:"20/80"}})
compare(jellyfin.operation.progress,"50/100")
compare(subsonic.operation.progress,"20/80")
}
function test_test_connection_is_not_login() {
QbzLocal.mediaStatus=JSON.stringify({subsonic:{phase:"reachable"}})
compare(subsonic.statusText,"Server reachable. Credentials have not been checked.")
}
}
}