feat(emby): support passwordless accounts (#442)

## Summary
- Allow Emby users with an explicitly empty password to authenticate.
- Preserve password whitespace and keep missing credentials or empty API
keys invalid.
- Accept passwordless credentials through the remote provider API and
expose the CLI `--no-password` option.
- Update proto comments and CLI documentation.

## Validation
- Media provider test suite: 552 passed, 5 ignored.
- Focused Emby, gRPC, CLI, formatting, clippy, proto freshness, and
documentation checks passed.
pull/443/head
zijiren 1 month ago committed by GitHub
parent c7aa2b0636
commit 0633b83b4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -426,6 +426,7 @@ Emby:
```bash
synctv provider emby login --username alice --server-endpoint https://emby.example --account-username alice --password 'pass' --instance-name emby-main
synctv provider emby login --username alice --server-endpoint https://emby.example --account-username guest --no-password --instance-name emby-main
synctv provider emby list --username alice --server-id <SERVER_ID> --path / --instance-name emby-main
synctv provider emby me --username alice --server-id <SERVER_ID> --instance-name emby-main
synctv provider emby binds --username alice

@ -438,6 +438,7 @@ Emby 服务操作:
```bash
synctv provider emby login --username alice --server-endpoint https://emby.example --account-username alice --password 'pass' --instance-name emby-main
synctv provider emby login --username alice --server-endpoint https://emby.example --account-username guest --no-password --instance-name emby-main
synctv provider emby list --username alice --server-id <SERVER_ID> --path / --instance-name emby-main
synctv provider emby me --username alice --server-id <SERVER_ID> --instance-name emby-main
synctv provider emby binds --username alice

@ -22,7 +22,7 @@ message LoginReq {
string host = 1;
string username = 2;
oneof credential {
string password = 3;
string password = 3; // Empty explicitly requests passwordless account authentication.
string api_key = 4;
}
}

@ -152,15 +152,8 @@ impl EmbyInterface for EmbyService {
}
match request.credential {
Some(crate::transport_dto::emby::login_req::Credential::Password(password)) => {
let password = password.trim();
if password.is_empty() {
return Err(EmbyError::InvalidConfig(
"password must not be empty".to_string(),
));
}
let mut client = self.anonymous_client(&request.host)?;
let (token, user_id) = client.login(username, password).await?;
let (token, user_id) = client.login(username, &password).await?;
let user_info = client.me().await?;
if !username_matches(&user_info.name, username) {

@ -23,6 +23,21 @@ pub struct EmbyService {
service: EmbyServiceImpl,
}
#[allow(clippy::result_large_err)]
fn validate_login_credential(
credential: Option<&super::emby::login_req::Credential>,
) -> Result<(), Status> {
match credential {
Some(super::emby::login_req::Credential::Password(_)) => Ok(()),
Some(super::emby::login_req::Credential::ApiKey(api_key)) => {
validate_required("api_key", api_key)
}
None => Err(Status::invalid_argument(
"exactly one of password or api_key must be provided",
)),
}
}
impl EmbyService {
pub fn new() -> Result<Self, reqwest::Error> {
Ok(Self {
@ -37,19 +52,7 @@ impl Emby for EmbyService {
let req = request.into_inner();
validate_provider_grpc_host(&req.host)?;
validate_required("username", &req.username)?;
match req.credential.as_ref() {
Some(super::emby::login_req::Credential::Password(password)) => {
validate_required("password", password)?;
}
Some(super::emby::login_req::Credential::ApiKey(api_key)) => {
validate_required("api_key", api_key)?;
}
None => {
return Err(Status::invalid_argument(
"exactly one of password or api_key must be provided",
));
}
}
validate_login_credential(req.credential.as_ref())?;
let resp = self
.service
.login(req)
@ -205,3 +208,23 @@ impl Emby for EmbyService {
Ok(Response::new(resp))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_credential_allows_explicit_empty_password() {
let credential = super::super::emby::login_req::Credential::Password(String::new());
assert!(validate_login_credential(Some(&credential)).is_ok());
}
#[test]
fn login_credential_still_rejects_missing_credentials_and_empty_api_keys() {
assert!(validate_login_credential(None).is_err());
let credential = super::super::emby::login_req::Credential::ApiKey(String::new());
assert!(validate_login_credential(Some(&credential)).is_err());
}
}

@ -3,6 +3,8 @@
//! Tests for item ID validation, API prefix detection, and HTTP API interactions using wiremock.
#![allow(clippy::unwrap_used)]
use synctv_media_providers::emby::{EmbyInterface, EmbyService};
use synctv_media_providers::transport_dto::emby::{login_req, LoginReq, LoginResp};
use synctv_media_providers::EmbyClient;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@ -28,6 +30,63 @@ async fn mock_public_info(server: &MockServer, prefix: &str) {
.await;
}
async fn login_through_service(password: &str) -> LoginResp {
let server = MockServer::start().await;
mock_public_info(&server, "/emby").await;
Mock::given(method("POST"))
.and(path("/emby/Users/authenticatebyname"))
.and(body_partial_json(serde_json::json!({
"Username": "guest",
"Pw": password,
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"AccessToken": "guest-token",
"User": {
"Id": "guest-id",
"Name": "guest"
}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/emby/Users/guest-id"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"Id": "guest-id",
"Name": "guest",
"ServerId": "server-1"
})))
.expect(1)
.mount(&server)
.await;
EmbyService::with_client(reqwest::Client::new())
.login(LoginReq {
host: server.uri(),
username: "guest".to_string(),
credential: Some(login_req::Credential::Password(password.to_string())),
})
.await
.expect("Emby password login should succeed")
}
#[tokio::test]
async fn test_emby_service_allows_passwordless_accounts() {
let response = login_through_service("").await;
assert_eq!(response.token, "guest-token");
assert_eq!(response.user_id, "guest-id");
}
#[tokio::test]
async fn test_emby_service_preserves_password_whitespace() {
let response = login_through_service(" secret ").await;
assert_eq!(response.token, "guest-token");
}
#[tokio::test]
async fn test_validate_item_id_normal() {
// Valid IDs should pass validation (but will fail on missing user_id since we don't set it)

@ -18,7 +18,7 @@ message LoginRequest {
string host = 1 [(buf.validate.field).string.min_len = 1];
string username = 2 [(buf.validate.field).string.min_len = 1]; // Target Emby username to bind and validate
oneof credential {
string password = 3; // SECURITY: Plaintext password - requires TLS in transit.
string password = 3; // Plaintext password; empty explicitly selects passwordless login. Requires TLS in transit.
string api_key = 4; // SECURITY: Plaintext credential - requires TLS in transit.
}
string instance_name = 5; // Optional provider instance name

@ -27,7 +27,7 @@ pub enum ProviderEmbySubcommand {
#[derive(Debug, Args)]
#[command(group(
ArgGroup::new("emby_login_credential")
.args(["password", "api_key"])
.args(["password", "no_password", "api_key"])
.required(true)
.multiple(false)
))]
@ -43,11 +43,15 @@ pub struct ProviderEmbyLoginArgs {
#[arg(long)]
pub account_username: String,
/// Emby/Jellyfin account password. Conflicts with --api-key.
/// Emby/Jellyfin account password. Conflicts with --no-password and --api-key.
#[arg(long, group = "emby_login_credential")]
pub password: Option<String>,
/// Emby/Jellyfin API key. Conflicts with --password.
/// Authenticate an Emby/Jellyfin account that has no password.
#[arg(long, group = "emby_login_credential")]
pub no_password: bool,
/// Emby/Jellyfin API key. Conflicts with --password and --no-password.
#[arg(long, group = "emby_login_credential")]
pub api_key: Option<String>,
@ -63,12 +67,17 @@ pub(in crate::cli) fn emby_login_credential(
synctv_proto::providers::emby::login_request::Credential::Password(password.clone()),
);
}
if args.no_password {
return Ok(
synctv_proto::providers::emby::login_request::Credential::Password(String::new()),
);
}
if let Some(api_key) = &args.api_key {
return Ok(
synctv_proto::providers::emby::login_request::Credential::ApiKey(api_key.clone()),
);
}
bail!("Emby login requires --password or --api-key")
bail!("Emby login requires --password, --no-password, or --api-key")
}
#[derive(Debug, Args)]

@ -3447,6 +3447,7 @@ fn cli_parses_provider_emby_login_with_api_key() {
assert_eq!(args.server_endpoint, "https://emby.example.com");
assert_eq!(args.account_username, "emby-user");
assert_eq!(args.password, None);
assert!(!args.no_password);
assert_eq!(args.api_key.as_deref(), Some("emby-api-key"));
assert_eq!(args.instance.instance_name.as_deref(), Some("emby-edge"));
}
@ -3479,12 +3480,47 @@ fn cli_parses_provider_emby_login_with_password() {
}) => {
assert_eq!(args.access.actor.username.as_deref(), Some("alice"));
assert_eq!(args.password.as_deref(), Some("secret"));
assert!(!args.no_password);
assert_eq!(args.api_key, None);
}
other => panic!("unexpected command parsed: {other:?}"),
}
}
#[test]
fn cli_parses_provider_emby_login_without_password() {
let cli = Cli::parse_from([
"synctv",
"provider",
"emby",
"login",
"--username",
"alice",
"--server-endpoint",
"https://emby.example.com",
"--account-username",
"emby-user",
"--no-password",
]);
match cli.command {
Commands::Provider(ProviderCommand {
command:
ProviderSubcommand::Emby(ProviderEmbyCommand {
command: ProviderEmbySubcommand::Login(args),
}),
}) => {
assert_eq!(args.password, None);
assert!(args.no_password);
assert_eq!(args.api_key, None);
assert_eq!(
emby_login_credential(&args).expect("no-password credential should build"),
synctv_proto::providers::emby::login_request::Credential::Password(String::new())
);
}
other => panic!("unexpected command parsed: {other:?}"),
}
}
#[test]
fn cli_parses_provider_emby_list() {
let cli = Cli::parse_from([
@ -3622,6 +3658,30 @@ fn cli_rejects_provider_emby_login_with_both_password_and_api_key() {
);
}
#[test]
fn cli_rejects_provider_emby_login_with_password_and_no_password() {
let result = Cli::try_parse_from([
"synctv",
"provider",
"emby",
"login",
"--username",
"alice",
"--server-endpoint",
"https://emby.example.com",
"--account-username",
"emby-user",
"--password",
"secret",
"--no-password",
]);
assert!(
result.is_err(),
"provider emby login must reject simultaneous password modes"
);
}
#[test]
fn cli_parses_provider_bilibili_parse() {
let cli = Cli::parse_from([

Loading…
Cancel
Save