{"record":{"id":"6ad7c5fe1d975418","repo":"Kareadita/Kavita","slug":"external-source-already-exists","errorCode":null,"errorMessage":"external-source-already-exists","messagePattern":"external-source-already-exists","errorType":"exception","errorClass":"KavitaException","httpStatus":400,"severity":"warning","filePath":"Kavita.Services/StreamService.cs","lineNumber":294,"sourceCode":"        user.SideNavStreams = list;\n\n        unitOfWork.UserRepository.Update(user);\n        await unitOfWork.CommitAsync(ct);\n        if (!stream.Visible) return;\n        await eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),\n            userId, ct);\n    }\n\n    public async Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto,\n        CancellationToken ct = default)\n    {\n        var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId,\n            AppUserIncludes.ExternalSources, ct);\n        if (user == null) throw new KavitaException(\"not-authenticated\");\n\n        if (user.ExternalSources.Any(s => s.Host == dto.Host))\n        {\n            throw new KavitaException(\"external-source-already-exists\");\n        }\n\n        if (string.IsNullOrEmpty(dto.Name)) throw new KavitaException(\"external-source-required\");\n        if (!UrlHelper.StartsWithHttpOrHttps(dto.Host)) throw new KavitaException(\"external-source-host-format\");\n\n\n        var newSource = new AppUserExternalSource()\n        {\n            Name = dto.Name,\n            Host = UrlHelper.EnsureEndsWithSlash(UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)),\n            ApiKey = dto.ApiKey\n        };\n        user.ExternalSources.Add(newSource);\n\n        unitOfWork.UserRepository.Update(user);\n        await unitOfWork.CommitAsync(ct);\n\n        dto.Id = newSource.Id;","sourceCodeStart":276,"sourceCodeEnd":312,"githubUrl":"https://github.com/Kareadita/Kavita/blob/9c3e5400007f8a0282f7d883f2ad5e71716e514d/Kavita.Services/StreamService.cs#L276-L312","documentation":"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.","triggerScenarios":"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).","commonSituations":"User re-adds an external server they already configured; copy-pasting the same host twice; the UI failed to reflect a prior successful save.","solutions":["Pre-check uniqueness with GET /api/streams/external-sources and compare normalized hosts (lower-case, scheme + trailing slash) before enabling Save.","Normalize the host input the same way the server does (force https, append trailing slash) before the duplicate comparison so equivalent hosts are detected.","Surface a clear inline error when a duplicate is detected rather than submitting."],"exampleFix":"// before\nsave(dto: ExternalSourceDto) {\n  return this.http.post('streams/create-external-source', dto);\n}\n// after\nsave(dto: ExternalSourceDto) {\n  const norm = normalizeHost(dto.host); // https + trailing slash\n  if (this.sources.some(s => normalizeHost(s.host) === norm)) {\n    return throwError(() => new Error('External source already exists'));\n  }\n  return this.http.post('streams/create-external-source', { ...dto, host: norm });\n}","handlingStrategy":"validation","validationCode":"function normalizeHost(host: string): string {\n  let h = host.trim().toLowerCase();\n  if (!/^https?:\\/\\//.test(h)) h = 'https://' + h;\n  if (!h.endsWith('/')) h += '/';\n  return h;\n}\nfunction isDuplicateExternalSource(existing: ExternalSourceDto[], host: string): boolean {\n  const norm = normalizeHost(host);\n  return existing.some(s => normalizeHost(s.host) === norm);\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Normalize host exactly as the server stores it (force https, trailing slash) before duplicate checks.","Load the external-sources list before showing the create form.","Show an inline duplicate warning instead of submitting."],"tags":["external-source","duplicate","validation"],"backgroundTag":null,"analyzedSha":"9c3e5400007f8a0282f7d883f2ad5e71716e514d","analyzedAt":"2026-08-13T19:06:05.897Z","schemaVersion":2},"datasetVersion":"2026-08-13T19:17:28.613Z"}