#1732 add CLI auth options. Add to the CLI these features which are already available in the GUI: importing from @mkb79 's audible-cli , and the external browser login (aka: alternative login). Caveat: 2nd feature doesn't work for Brazil (gui or cli)

This commit is contained in:
rmcrackan
2026-04-18 15:29:57 -04:00
parent f2ef616203
commit 6d326ebabc
10 changed files with 328 additions and 23 deletions

View File

@@ -0,0 +1,44 @@
using System.Linq;
using System.Threading.Tasks;
namespace AudibleUtilities;
public enum Mkb79ImportOutcome
{
Success,
DuplicateAccount,
InvalidFile,
}
public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
public static class Mkb79AuthImporter
{
/// <summary>
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
/// </summary>
public static async Task<Mkb79ImportResult> ImportFromJsonTextAsync(string jsonText)
{
var mkbAuth = Mkb79Auth.FromJson(jsonText);
if (mkbAuth is null)
{
return new Mkb79ImportResult(
Mkb79ImportOutcome.InvalidFile,
null,
"File did not contain valid mkb79/audible-cli account data.");
}
var account = await mkbAuth.ToAccountAsync();
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a =>
a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
{
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
}
persister.AccountsSettings.Add(account);
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
}
}

View File

@@ -121,21 +121,22 @@ public partial class AccountsDialog : DialogWindow
try
{
var jsonText = File.ReadAllText(selectedFile);
var mkbAuth = Mkb79Auth.FromJson(jsonText) ?? throw new Exception("File did not contain valid mkb79/audible-cli account data.");
var account = await mkbAuth.ToAccountAsync();
var importResult = await Mkb79AuthImporter.ImportFromJsonTextAsync(jsonText);
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
if (importResult.Outcome is Mkb79ImportOutcome.InvalidFile)
{
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale?.Name}", "Cannot Add Duplicate Account");
await MessageBox.Show(this, importResult.Message ?? "Invalid import file.", "Error Importing Account", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
persister.AccountsSettings.Add(account);
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
{
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
return;
}
Accounts.Add(new AccountDto(account));
if (importResult.Account is { } account)
Accounts.Add(new AccountDto(account));
}
catch (Exception ex)
{

View File

@@ -0,0 +1,78 @@
using AudibleUtilities;
using CommandLine;
using System;
using System.IO;
using System.Threading.Tasks;
namespace LibationCli;
[Verb("import-account", HelpText = "Import an Audible account from an mkb79/audible-cli JSON export file.")]
internal class ImportAccountOptions : OptionsBase
{
[Value(0, MetaName = "path", Required = true, HelpText = "Path to the exported account JSON file.")]
public string? JsonFilePath { get; set; }
protected override async Task ProcessAsync()
{
var path = JsonFilePath?.Trim();
if (string.IsNullOrEmpty(path))
{
PrintVerbUsage("ERROR", "=====", "Path to JSON file is required.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
if (!File.Exists(path))
{
PrintVerbUsage("ERROR", "=====", $"File not found: {path}");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
string jsonText;
try
{
jsonText = await File.ReadAllTextAsync(path);
}
catch (Exception ex)
{
PrintVerbUsage("ERROR", "=====", $"Could not read file: {ex.Message}");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
Mkb79ImportResult result;
try
{
result = await Mkb79AuthImporter.ImportFromJsonTextAsync(jsonText);
}
catch (Exception ex)
{
PrintVerbUsage("ERROR", "=====", ex.Message, "", ex.StackTrace);
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
switch (result.Outcome)
{
case Mkb79ImportOutcome.InvalidFile:
Console.Error.WriteLine(result.Message ?? "Invalid import file.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
case Mkb79ImportOutcome.DuplicateAccount when result.Account is { } dup:
Console.Error.WriteLine(
$"An account with that account id and country already exists.{Environment.NewLine}"
+ $"Account ID: {dup.AccountId}{Environment.NewLine}Country: {dup.Locale?.Name}");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
case Mkb79ImportOutcome.Success when result.Account is { } account:
Console.WriteLine(
$"Imported account: {account.AccountName} ({account.AccountId}, {account.Locale?.Name})");
return;
default:
Console.Error.WriteLine("Unexpected import result.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
}
}

View File

@@ -0,0 +1,121 @@
using AudibleApi;
using AudibleUtilities;
using CommandLine;
using System;
using System.Net;
using System.Threading.Tasks;
namespace LibationCli;
[Verb("login-external", HelpText = "Sign in with Audible using an external browser: open the printed URL, then paste the final URL from the address bar.")]
internal class LoginExternalOptions : OptionsBase
{
[Option('a', "account", Required = true, HelpText = "Audible login id (email) for this account.")]
public string? AccountId { get; set; }
[Option('l', "locale", Required = true, HelpText = "Audible marketplace / locale (e.g. us, uk, de).")]
public string? Locale { get; set; }
[Option("response-url", Required = false, HelpText = "Final browser URL after login. Use when stdin is not a TTY (e.g. scripts, Docker).")]
public string? ResponseUrl { get; set; }
protected override async Task ProcessAsync()
{
var accountId = AccountId?.Trim();
var localeName = Locale?.Trim();
if (string.IsNullOrEmpty(accountId) || string.IsNullOrEmpty(localeName))
{
PrintVerbUsage("ERROR", "=====", "Both --account and --locale are required.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
Locale locale;
try
{
locale = Localization.Get(localeName);
}
catch (Exception ex)
{
PrintVerbUsage("ERROR", "=====", $"Unknown locale '{localeName}': {ex.Message}");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister.AccountsSettings.Upsert(accountId, localeName);
if (account.IdentityTokens?.IsValid == true)
{
Console.WriteLine(
$"Account '{accountId}' ({localeName}) is already authenticated. No browser login needed.");
return;
}
var presetResponse = ResponseUrl?.Trim();
if (string.IsNullOrEmpty(presetResponse) && Console.IsInputRedirected)
{
Console.Error.WriteLine(
"Standard input is redirected. Provide the post-login URL with --response-url \"...\".");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
var loginExternal = new CliLoginExternal(presetResponse);
try
{
_ = await EzApiCreator.GetApiAsync(
loginExternal,
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
}
catch (Exception ex)
{
PrintVerbUsage("ERROR", "=====", ex.Message, "", ex.StackTrace);
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
Console.WriteLine($"Successfully authenticated account '{accountId}' ({localeName}).");
}
private sealed class CliLoginExternal : ILoginExternal
{
private readonly string? _presetResponseUrl;
public CliLoginExternal(string? presetResponseUrl) => _presetResponseUrl = presetResponseUrl;
public string DeviceName => "Libation";
public string GetResponseUrl(string loginUrl, CookieCollection signInCookies)
{
if (!string.IsNullOrEmpty(_presetResponseUrl))
return ValidateResponseUrl(_presetResponseUrl);
Console.WriteLine();
Console.WriteLine("Open this URL in your web browser and sign in:");
Console.WriteLine(loginUrl);
Console.WriteLine();
Console.WriteLine(
"After you finish signing in, copy the full URL from your browser's address bar and paste it below.");
Console.WriteLine("(It is normal if the page says it does not exist.)");
Console.WriteLine();
Console.Write("Paste URL: ");
var line = Console.ReadLine()?.Trim();
if (string.IsNullOrEmpty(line))
throw new OperationCanceledException("No response URL was entered.");
return ValidateResponseUrl(line);
}
private static string ValidateResponseUrl(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out _))
throw new ArgumentException("The response URL must be a valid absolute URL.");
return url;
}
}
}

View File

@@ -279,21 +279,22 @@ public partial class AccountsDialog : Form
try
{
var jsonText = File.ReadAllText(ofd.FileName);
var mkbAuth = Mkb79Auth.FromJson(jsonText) ?? throw new Exception("File did not contain valid mkb79/audible-cli account data.");
var account = await mkbAuth.ToAccountAsync();
var importResult = await Mkb79AuthImporter.ImportFromJsonTextAsync(jsonText);
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
if (importResult.Outcome is Mkb79ImportOutcome.InvalidFile)
{
MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale?.Name}", "Cannot Add Duplicate Account");
MessageBox.Show(this, importResult.Message ?? "Invalid import file.", "Error Importing Account", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
persister.AccountsSettings.Add(account);
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
{
MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
return;
}
AddAccountToGrid(account);
if (importResult.Account is { } account)
AddAccountToGrid(account);
}
catch (Exception ex)
{

View File

@@ -10,9 +10,9 @@ Warnings about relying solely on on the CLI:
## Progress Bar
The **liberate** and **convert** commands show a progress bar in the terminal while downloading or converting (e.g. `[##########----------] 2.5 min remaining`). The progress bar is only shown when the CLI is run interactively with output not redirected.
The `liberate` and `convert` commands show a progress bar in the terminal while downloading or converting (e.g. `[##########----------] 2.5 min remaining`). The progress bar is only shown when the CLI is run interactively with output not redirected.
To **turn off the progress bar** (for scripting, logging, or cleaner output), redirect standard output and/or standard error. The progress bar is automatically disabled when either stream is redirected.
To turn off the progress bar (for scripting, logging, or cleaner output), redirect standard output and/or standard error. The progress bar is automatically disabled when either stream is redirected.
```console
libationcli liberate > log.txt 2>&1
@@ -33,6 +33,52 @@ libationcli --help
libationcli scan --help
```
## Libation files location
All verbs use the same Libation data directory as the GUI (where `AccountsSettings.json` and `Settings.json` live). To point the CLI elsewhere:
```console
libationcli --libationFiles "D:\path\to\LibationFiles" scan
```
You can also set the environment variable `LIBATION_FILES_DIR` to that directory instead of passing `--libationFiles` every time.
## Import an account (mkb79 / audible-cli JSON)
Imports a single account from a JSON file in the format produced by [mkb79's audible-cli](https://github.com/mkb79/audible-cli) export (Libation's GUI export to the same format is compatible). The file is validated, tokens are refreshed, and the account is appended to `AccountsSettings.json` unless the same account id and locale already exist.
```console
libationcli import-account "C:\path\to\account.json"
libationcli import-account /home/me/Audible/account.json
```
Use `libationcli import-account --help` for the exact options on your build.
## Log in with an external browser (`login-external`)
For headless servers or when you prefer not to use the GUI, this verb performs the same external browser OAuth flow as Libation's alternate login: the CLI prints a sign-in URL, you complete login in your own browser, then supply the full URL shown in the browser after Audible redirects you (it is normal if that page looks broken or says the page does not exist).
Required flags:
- `--account` / `-a` — Your Audible login id (email).
- `--locale` / `-l` — Marketplace code, same as in the GUI (for example `us`, `uk`, `de`).
Interactive use (terminal attached to a keyboard):
```console
libationcli login-external --account you@example.com --locale us
```
Non-interactive use (stdin redirected, Docker without `-t`, scripts): pass the post-login URL explicitly:
```console
libationcli login-external -a you@example.com -l us --response-url "https://www.amazon.com/ap/maplanding?..."
```
If the account row already has valid saved tokens, the CLI reports that no browser login is needed and exits without opening the flow.
Use `libationcli login-external --help` for the exact options on your build.
## Scan All Libraries
```console

View File

@@ -22,7 +22,7 @@ Hangover is located inside the app bundle. Either:
The installer creates shortcuts for `libation`, `libationcli`, and `hangover`. From a terminal, run `hangover`.
### Linux: in-app Audible login or add account fails
### Linux: in-app Audible login or "add account" fails
Embedded sign-in uses WebKit2GTK (`libwebkit2gtk`). If that native stack is missing, install the packages for your distro or use 'external browser' sign-in in Libation's import/library settings. Details: [Install on Linux](/docs/installation/linux) (section: Runtime dependencies (Audible sign-in)).

View File

@@ -35,7 +35,7 @@ nix-shell
This will drop you into the shell environment defined in `shell.nix`. Note that this is not flake-native method and does not use the locked nixpkgs in `flake.lock` so exact versions of the dependancies is not guaranteed.
## Whats Inside the Dev Shell?
## What's Inside the Dev Shell?
- The environment variables and packages configured in `shell.nix` will be available.
- The package set (`pkgs`) used aligns with the versions locked in `flake.lock` to ensure reproducibility.

View File

@@ -181,7 +181,7 @@ For more custom formatters and examples, [see this guide from Microsoft](https:/
| \\ | The escape character. | \<minutes[d\\d h\\h m\\m]\> | 2d 14h 42m |
These formatters have been enhanced to allow the display of days, hours or months beyond their usual limits. For example, the total number of hours, even if it exceeds 23.
Here, a number format is inserted for the desired part in accordance with [Microsofts instructions](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings). Unlike standard number formats, however, the letters D, H or M (uppercase) are used instead of zeros.
Here, a number format is inserted for the desired part in accordance with [Microsoft's instructions](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings). Unlike standard number formats, however, the letters D, H or M (uppercase) are used instead of zeros.
| Formatter | Description | Example Usage | Example Result |
|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------|-------------------|

View File

@@ -14,6 +14,20 @@ The docker image is provided as-is. We hope it can be useful to you but it is no
Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values.
### Adding Audible accounts without the GUI
If you run Libation on a server or in Docker and do not want to copy `AccountsSettings.json` from a desktop install, you can create or update accounts with LibationCli (same binary as in the image under `/libation/LibationCli`):
- `import-account` — Import an account from a JSON file exported by [mkb79's audible-cli](https://github.com/mkb79/audible-cli) (or Libation's own compatible export). Example:
`LibationCli import-account /path/to/account.json`
- `login-external` — Browser-based sign-in: the CLI prints an Audible login URL; you open it in a normal browser, sign in, then paste the final URL from the address bar back into the terminal. Example:
`LibationCli login-external --account you@example.com --locale us`
If standard input is not a TTY (for example in some automation), pass the final URL with `--response-url "https://..."` instead of pasting interactively.
For full syntax, overrides, and the `--libationFiles` option (or the `LIBATION_FILES_DIR` environment variable) when your Libation data directory is not the default, see [Command Line Interface](/docs/advanced/command-line-interface).
Docker tip: The entrypoint script copies your mounted config into an internal path before running LibationCli. The most reliable way to run account commands inside the same layout the container uses is to `docker exec` into an already running Libation container (so `AccountsSettings.json` and `appsettings.json` are already in place), then run `/libation/LibationCli import-account ...` or `/libation/LibationCli login-external ...`. Alternatively, run LibationCli on any host where you can point `--libationFiles` (or `LIBATION_FILES_DIR`) at the folder that you later mount as `/config` on the server.
## Running
Once the configuration files are copied, the docker image can be run with the following command.