Add Mega-Debrid multi-account support with automatic fallback
Multiple Mega-Debrid accounts can now be configured as login:password pairs (one per line). When an account hits Fair-Use limits or errors, the next account is tried automatically. - New parser module mega-debrid-accounts.ts (parse, ID generation, masking, serialization) - Per-account daily limits, usage tracking, enable/disable - Account rotation with per-mode cooldowns (API failures don't block Web attempts) - Backward compatible: existing single megaLogin/megaPassword is auto-migrated to the new format - UI: textarea for credentials, account list with masked logins Follows the existing Debrid-Link multi-key pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
672c74f98f
commit
6df0834b67
@@ -0,0 +1,96 @@
|
||||
export interface MegaDebridAccountEntry {
|
||||
id: string;
|
||||
login: string;
|
||||
password: string;
|
||||
index: number;
|
||||
label: string;
|
||||
maskedLogin: string;
|
||||
}
|
||||
|
||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV64_PRIME = 0x100000001b3n;
|
||||
const FNV64_MASK = 0xffffffffffffffffn;
|
||||
|
||||
function fnv1a64(text: string): string {
|
||||
let hash = FNV64_OFFSET_BASIS;
|
||||
for (const char of text) {
|
||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountId(login: string): string {
|
||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
export function maskMegaDebridLogin(login: string): string {
|
||||
const trimmed = login.trim();
|
||||
if (!trimmed) {
|
||||
return "Nicht hinterlegt";
|
||||
}
|
||||
if (trimmed.length <= 4) {
|
||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
||||
}
|
||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountLabel(index: number): string {
|
||||
return `Account ${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse newline-separated "login:password" pairs.
|
||||
* Falls back to treating the entire string as a single login if no colon
|
||||
* is found (backward compat with old megaLogin field).
|
||||
*/
|
||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const lines = String(raw || "")
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const entries: MegaDebridAccountEntry[] = [];
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
let login: string;
|
||||
let password: string;
|
||||
if (colonIdx >= 0) {
|
||||
login = line.slice(0, colonIdx).trim();
|
||||
password = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
// Legacy format: just a login, use the provided fallback password
|
||||
login = line;
|
||||
password = legacyPassword;
|
||||
}
|
||||
if (!login || !password) {
|
||||
continue;
|
||||
}
|
||||
const key = login.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push({
|
||||
id: getMegaDebridAccountId(login),
|
||||
login,
|
||||
password,
|
||||
index: entries.length,
|
||||
label: getMegaDebridAccountLabel(entries.length),
|
||||
maskedLogin: maskMegaDebridLogin(login)
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
||||
return accounts
|
||||
.filter((a) => a.login.trim() && a.password.trim())
|
||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ export type DebridLinkKeyByteMap = Record<string, number>;
|
||||
|
||||
type ProviderDailySettings =
|
||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>;
|
||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
||||
|
||||
type ProviderUsageSettings =
|
||||
ProviderDailySettings
|
||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>;
|
||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
||||
|
||||
function normalizePositiveBytes(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
@@ -247,3 +249,83 @@ export function addDebridLinkApiKeyTotalUsageBytes(
|
||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mega-Debrid per-account limits ──
|
||||
|
||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): number {
|
||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function isMegaDebridAccountDailyLimitReached(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
epochMs = Date.now()
|
||||
): boolean {
|
||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountDailyUsageBytes(
|
||||
settings: ProviderDailySettings,
|
||||
accountId: string,
|
||||
byteDelta: number,
|
||||
epochMs = Date.now()
|
||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const dayKey = getProviderUsageDayKey(epochMs);
|
||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
||||
: {};
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
providerDailyUsageDay: dayKey,
|
||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
export function addMegaDebridAccountTotalUsageBytes(
|
||||
settings: ProviderUsageSettings,
|
||||
accountId: string,
|
||||
byteDelta: number
|
||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
||||
const increment = normalizePositiveBytes(byteDelta);
|
||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
||||
if (increment <= 0) {
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
||||
|
||||
return {
|
||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
||||
};
|
||||
}
|
||||
|
||||
+151
-146
@@ -27,9 +27,9 @@ export type DebridProvider =
|
||||
| "linksnappy";
|
||||
export type DebridFallbackProvider = DebridProvider | "none";
|
||||
export type AppTheme = "dark" | "light";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||
export type PackagePriority = "high" | "normal" | "low";
|
||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
||||
|
||||
export interface BandwidthScheduleEntry {
|
||||
id: string;
|
||||
@@ -39,25 +39,26 @@ export interface BandwidthScheduleEntry {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadStats {
|
||||
totalDownloaded: number;
|
||||
totalDownloadedAllTime: number;
|
||||
totalFiles?: number;
|
||||
totalFilesSession: number;
|
||||
totalFilesAllTime: number;
|
||||
totalPackages: number;
|
||||
sessionStartedAt: number;
|
||||
appSessionStartedAt: number;
|
||||
sessionRuntimeMs: number;
|
||||
totalRuntimeMs: number;
|
||||
runtimeMeasuredAt: number;
|
||||
}
|
||||
export interface DownloadStats {
|
||||
totalDownloaded: number;
|
||||
totalDownloadedAllTime: number;
|
||||
totalFiles?: number;
|
||||
totalFilesSession: number;
|
||||
totalFilesAllTime: number;
|
||||
totalPackages: number;
|
||||
sessionStartedAt: number;
|
||||
appSessionStartedAt: number;
|
||||
sessionRuntimeMs: number;
|
||||
totalRuntimeMs: number;
|
||||
runtimeMeasuredAt: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
token: string;
|
||||
realDebridUseWebLogin: boolean;
|
||||
megaLogin: string;
|
||||
megaPassword: string;
|
||||
megaCredentials: string;
|
||||
megaDebridApiEnabled: boolean;
|
||||
megaDebridWebEnabled: boolean;
|
||||
megaDebridPreferApi: boolean;
|
||||
@@ -74,7 +75,7 @@ export interface AppSettings {
|
||||
linkSnappyPassword: string;
|
||||
archivePasswordList: string;
|
||||
rememberToken: boolean;
|
||||
providerOrder: readonly DebridProvider[];
|
||||
providerOrder: readonly DebridProvider[];
|
||||
providerPrimary: DebridProvider;
|
||||
providerSecondary: DebridFallbackProvider;
|
||||
providerTertiary: DebridFallbackProvider;
|
||||
@@ -107,32 +108,36 @@ export interface AppSettings {
|
||||
autoUpdateCheck: boolean;
|
||||
clipboardWatch: boolean;
|
||||
minimizeToTray: boolean;
|
||||
theme: AppTheme;
|
||||
collapseNewPackages: boolean;
|
||||
historyRetentionMode: HistoryRetentionMode;
|
||||
accountListShowDetailedDebridLinkKeys: boolean;
|
||||
autoSortPackagesByProgress: boolean;
|
||||
theme: AppTheme;
|
||||
collapseNewPackages: boolean;
|
||||
historyRetentionMode: HistoryRetentionMode;
|
||||
accountListShowDetailedDebridLinkKeys: boolean;
|
||||
autoSortPackagesByProgress: boolean;
|
||||
autoSkipExtracted: boolean;
|
||||
hideExtractedItems: boolean;
|
||||
confirmDeleteSelection: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||
columnOrder: string[];
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
hideExtractedItems: boolean;
|
||||
confirmDeleteSelection: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||
columnOrder: string[];
|
||||
extractCpuPriority: ExtractCpuPriority;
|
||||
autoExtractWhenStopped: boolean;
|
||||
disabledProviders: DebridProvider[];
|
||||
hosterRouting: Record<string, DebridProvider>;
|
||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
||||
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
|
||||
providerDailyUsageDay: string;
|
||||
scheduledStartEpochMs: number;
|
||||
}
|
||||
hosterRouting: Record<string, DebridProvider>;
|
||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
|
||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
||||
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
|
||||
megaDebridDisabledAccountIds: string[];
|
||||
megaDebridAccountDailyLimitBytes: Record<string, number>;
|
||||
megaDebridAccountDailyUsageBytes: Record<string, number>;
|
||||
megaDebridAccountTotalUsageBytes: Record<string, number>;
|
||||
providerDailyUsageDay: string;
|
||||
scheduledStartEpochMs: number;
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
id: string;
|
||||
@@ -167,14 +172,14 @@ export interface PackageEntry {
|
||||
status: DownloadStatus;
|
||||
itemIds: string[];
|
||||
cancelled: boolean;
|
||||
enabled: boolean;
|
||||
priority?: PackagePriority;
|
||||
postProcessLabel?: string;
|
||||
downloadStartedAt?: number;
|
||||
downloadCompletedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
enabled: boolean;
|
||||
priority?: PackagePriority;
|
||||
postProcessLabel?: string;
|
||||
downloadStartedAt?: number;
|
||||
downloadCompletedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
version: number;
|
||||
@@ -338,104 +343,104 @@ export interface BandwidthStats {
|
||||
sessionDurationSeconds: number;
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
bandwidth: BandwidthStats;
|
||||
totalDownloads: number;
|
||||
completedDownloads: number;
|
||||
failedDownloads: number;
|
||||
activeDownloads: number;
|
||||
queuedDownloads: number;
|
||||
}
|
||||
|
||||
export interface SupportTraceConfig {
|
||||
enabled: boolean;
|
||||
includeMainLog: boolean;
|
||||
includeAudit: boolean;
|
||||
logDebugRequests: boolean;
|
||||
autoDisableAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupportFileSizeInfo {
|
||||
path: string | null;
|
||||
exists: boolean;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDirectorySizeInfo {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
fileCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDiskSpaceInfo {
|
||||
path: string;
|
||||
totalBytes: number | null;
|
||||
freeBytes: number | null;
|
||||
freePercent: number | null;
|
||||
}
|
||||
|
||||
export interface SupportBundleEstimate {
|
||||
estimatedBytes: number;
|
||||
estimatedEntries: number;
|
||||
duplicatedLiveLogBytes: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface DebugSetupCheckResult {
|
||||
status: "ok" | "warn";
|
||||
enabled: boolean;
|
||||
runtimeBaseDir: string;
|
||||
host: string;
|
||||
port: number;
|
||||
localOnly: boolean;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath: string;
|
||||
aiManifestPath: string;
|
||||
aiManifestPresent: boolean;
|
||||
traceConfigPath: string | null;
|
||||
traceLogPath: string | null;
|
||||
traceEnabled: boolean;
|
||||
traceAutoDisableAt: string | null;
|
||||
diskSpace: {
|
||||
runtime: SupportDiskSpaceInfo;
|
||||
output: SupportDiskSpaceInfo;
|
||||
extract: SupportDiskSpaceInfo;
|
||||
};
|
||||
logSummary: {
|
||||
totalBytes: number;
|
||||
main: SupportFileSizeInfo;
|
||||
mainBackup: SupportFileSizeInfo;
|
||||
audit: SupportFileSizeInfo;
|
||||
auditBackup: SupportFileSizeInfo;
|
||||
rename: SupportFileSizeInfo;
|
||||
renameBackup: SupportFileSizeInfo;
|
||||
session: SupportFileSizeInfo;
|
||||
trace: SupportFileSizeInfo;
|
||||
traceBackup: SupportFileSizeInfo;
|
||||
sessionLogs: SupportDirectorySizeInfo;
|
||||
packageLogs: SupportDirectorySizeInfo;
|
||||
itemLogs: SupportDirectorySizeInfo;
|
||||
};
|
||||
supportBundle: SupportBundleEstimate;
|
||||
warnings: string[];
|
||||
notes: string[];
|
||||
localUrls: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
remoteUrlTemplates: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
export interface SessionStats {
|
||||
bandwidth: BandwidthStats;
|
||||
totalDownloads: number;
|
||||
completedDownloads: number;
|
||||
failedDownloads: number;
|
||||
activeDownloads: number;
|
||||
queuedDownloads: number;
|
||||
}
|
||||
|
||||
export interface SupportTraceConfig {
|
||||
enabled: boolean;
|
||||
includeMainLog: boolean;
|
||||
includeAudit: boolean;
|
||||
logDebugRequests: boolean;
|
||||
autoDisableAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupportFileSizeInfo {
|
||||
path: string | null;
|
||||
exists: boolean;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDirectorySizeInfo {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
fileCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface SupportDiskSpaceInfo {
|
||||
path: string;
|
||||
totalBytes: number | null;
|
||||
freeBytes: number | null;
|
||||
freePercent: number | null;
|
||||
}
|
||||
|
||||
export interface SupportBundleEstimate {
|
||||
estimatedBytes: number;
|
||||
estimatedEntries: number;
|
||||
duplicatedLiveLogBytes: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface DebugSetupCheckResult {
|
||||
status: "ok" | "warn";
|
||||
enabled: boolean;
|
||||
runtimeBaseDir: string;
|
||||
host: string;
|
||||
port: number;
|
||||
localOnly: boolean;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath: string;
|
||||
aiManifestPath: string;
|
||||
aiManifestPresent: boolean;
|
||||
traceConfigPath: string | null;
|
||||
traceLogPath: string | null;
|
||||
traceEnabled: boolean;
|
||||
traceAutoDisableAt: string | null;
|
||||
diskSpace: {
|
||||
runtime: SupportDiskSpaceInfo;
|
||||
output: SupportDiskSpaceInfo;
|
||||
extract: SupportDiskSpaceInfo;
|
||||
};
|
||||
logSummary: {
|
||||
totalBytes: number;
|
||||
main: SupportFileSizeInfo;
|
||||
mainBackup: SupportFileSizeInfo;
|
||||
audit: SupportFileSizeInfo;
|
||||
auditBackup: SupportFileSizeInfo;
|
||||
rename: SupportFileSizeInfo;
|
||||
renameBackup: SupportFileSizeInfo;
|
||||
session: SupportFileSizeInfo;
|
||||
trace: SupportFileSizeInfo;
|
||||
traceBackup: SupportFileSizeInfo;
|
||||
sessionLogs: SupportDirectorySizeInfo;
|
||||
packageLogs: SupportDirectorySizeInfo;
|
||||
itemLogs: SupportDirectorySizeInfo;
|
||||
};
|
||||
supportBundle: SupportBundleEstimate;
|
||||
warnings: string[];
|
||||
notes: string[];
|
||||
localUrls: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
remoteUrlTemplates: {
|
||||
health: string;
|
||||
meta: string;
|
||||
diagnostics: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
totalBytes: number;
|
||||
downloadedBytes: number;
|
||||
fileCount: number;
|
||||
|
||||
Reference in New Issue
Block a user