Kareadita/Kavita · warning · KavitaException

external-source-already-exists

Error message

external-source-already-exists

What it means

Thrown by StreamService.CreateExternalSource when the user already has an AppUserExternalSource whose Host equals the raw dto.Host. Host uniqueness is enforced per user. Gotcha: the duplicate check compares the RAW dto.Host against the stored Host, but stored hosts are normalized on save via EnsureStartsWithHttpOrHttps + EnsureEndsWithSlash, so the same logical host entered with a different trailing-slash or scheme can slip past this check. It is a localized KavitaException surfaced as HTTP 500.

Source

Thrown at Kavita.Services/StreamService.cs:294

        user.SideNavStreams = list;

        unitOfWork.UserRepository.Update(user);
        await unitOfWork.CommitAsync(ct);
        if (!stream.Visible) return;
        await eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
            userId, ct);
    }

    public async Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto,
        CancellationToken ct = default)
    {
        var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId,
            AppUserIncludes.ExternalSources, ct);
        if (user == null) throw new KavitaException("not-authenticated");

        if (user.ExternalSources.Any(s => s.Host == dto.Host))
        {
            throw new KavitaException("external-source-already-exists");
        }

        if (string.IsNullOrEmpty(dto.Name)) throw new KavitaException("external-source-required");
        if (!UrlHelper.StartsWithHttpOrHttps(dto.Host)) throw new KavitaException("external-source-host-format");


        var newSource = new AppUserExternalSource()
        {
            Name = dto.Name,
            Host = UrlHelper.EnsureEndsWithSlash(UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)),
            ApiKey = dto.ApiKey
        };
        user.ExternalSources.Add(newSource);

        unitOfWork.UserRepository.Update(user);
        await unitOfWork.CommitAsync(ct);

        dto.Id = newSource.Id;

View on GitHub (pinned to 9c3e540000)

Solutions

  1. Pre-check uniqueness with GET /api/streams/external-sources and compare normalized hosts (lower-case, scheme + trailing slash) before enabling Save.
  2. Normalize the host input the same way the server does (force https, append trailing slash) before the duplicate comparison so equivalent hosts are detected.
  3. Surface a clear inline error when a duplicate is detected rather than submitting.

Example fix

// before
save(dto: ExternalSourceDto) {
  return this.http.post('streams/create-external-source', dto);
}
// after
save(dto: ExternalSourceDto) {
  const norm = normalizeHost(dto.host); // https + trailing slash
  if (this.sources.some(s => normalizeHost(s.host) === norm)) {
    return throwError(() => new Error('External source already exists'));
  }
  return this.http.post('streams/create-external-source', { ...dto, host: norm });
}
Defensive patterns

Strategy: validation

Validate before calling

function normalizeHost(host: string): string {
  let h = host.trim().toLowerCase();
  if (!/^https?:\/\//.test(h)) h = 'https://' + h;
  if (!h.endsWith('/')) h += '/';
  return h;
}
function isDuplicateExternalSource(existing: ExternalSourceDto[], host: string): boolean {
  const norm = normalizeHost(host);
  return existing.some(s => normalizeHost(s.host) === norm);
}

Prevention

When it happens

Trigger: POST /api/streams/create-external-source with a dto whose Host matches an existing AppUserExternalSource.Host for the calling user exactly (byte-for-byte, as previously normalized).

Common situations: User re-adds an external server they already configured; copy-pasting the same host twice; the UI failed to reflect a prior successful save.

Related errors


AI-assisted analysis of Kareadita/Kavita@9c3e540000 (2026-08-13). Data as JSON: /api/errors/6ad7c5fe1d975418. Report an issue: GitHub.