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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/content/docs/en/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion synctv-media-providers/proto/emby.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
9 changes: 1 addition & 8 deletions synctv-media-providers/src/emby/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
49 changes: 36 additions & 13 deletions synctv-media-providers/src/grpc/emby_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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());
}
}
59 changes: 59 additions & 0 deletions synctv-media-providers/tests/emby_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion synctv-proto/proto/providers/emby.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions synctv/src/cli/commands/provider/emby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
))]
Expand All @@ -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>,

Expand All @@ -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)]
Expand Down
60 changes: 60 additions & 0 deletions synctv/src/cli/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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([
Expand Down
Loading