Comick-specific manga source client. Extends the base client with Comick-specific API parsing and IPC communication. Uses IPC to avoid CORS issues when calling Comick API from renderer process.

export class ComickClient extends BaseMangaSourceClient<
ComickManga,
ComickMangaDetail
> {
constructor() {
super(COMICK_CONFIG);
}

/**
* Search for manga on Comick API via IPC (to avoid CORS issues).
* Results are cached for 30 minutes by default.
* @param query - The search query string.
* @param limit - Maximum number of results to return (default: 10).
* @returns Promise resolving to array of Comick manga entries.
* @source
*/
public async searchManga(
query: string,
limit: number = 10,
): Promise<ComickManga[]> {
return withGroupAsync(`[Comick] Search: "${query}"`, async () => {
// Check cache first
const cacheKey = `search:${query.toLowerCase()}:${limit}`;
const cached = this.getCachedData<ComickManga[]>(cacheKey);
if (cached) {
console.debug(`[Comick] 📦 Using cached results for "${query}"`);
return cached;
}

console.info(
`[Comick] 🔍 Searching Comick for: "${query}" (limit: ${limit})`,
);

try {
// Use generic manga source API to call main process via IPC instead of direct fetch (CORS)
const data = (await globalThis.electronAPI.mangaSource.search(
MangaSource.Comick,
query,
limit,
)) as ComickManga[];

const results = this.parseSearchResponse(data);
this.setCachedData(cacheKey, results);

console.info(
`[Comick] ✅ Comick search found ${results?.length || 0} results for "${query}"`,
);
return results;
} catch (error) {
console.error(
`[Comick] ❌ Comick search failed for "${query}":`,
error,
);
return [];
}
});
} /**
* Get detailed information about a specific Comick manga using IPC.
* @param slug - The Comick manga slug.
* @returns Promise resolving to manga detail or null if not found.
* @source
*/
public async getMangaDetail(slug: string): Promise<ComickMangaDetail | null> {
return withGroupAsync(`[Comick] Detail: "${slug}"`, async () => {
console.debug(`[Comick] 📖 Getting Comick manga details for: ${slug}`);

try {
// Use generic manga source API to call main process via IPC instead of direct fetch (CORS)
const rawData = await globalThis.electronAPI.mangaSource.getMangaDetail(
MangaSource.Comick,
slug,
);
const detail = this.parseDetailResponse(rawData);
if (detail) {
console.info(`[Comick] ✅ Retrieved details for manga ${slug}`);
}
return detail;
} catch (error) {
console.error(
`[Comick] ❌ Failed to get Comick manga details for ${slug}:`,
error,
);
return null;
}
});
}

/**
* Parse raw search response into Comick manga entries.
* Maps raw API fields to standardized ComickManga interface with validation.
* @param rawResponse - The raw API response array.
* @returns Array of parsed manga entries, skipping any invalid items.
* @source
*/
protected parseSearchResponse(rawResponse: unknown): ComickManga[] {
if (!Array.isArray(rawResponse)) return [];

return rawResponse
.map((item: unknown): ComickManga | null => {
// Validate required fields exist
if (
!item ||
typeof item !== "object" ||
!("id" in item) ||
!("title" in item) ||
!("slug" in item)
) {
console.warn("[Comick] Skipping invalid search result item", item);
return null;
}

const obj = item as Record<string, unknown>;
return {
id: String(obj.id),
title: String(obj.title),
slug: String(obj.slug),
year: typeof obj.year === "number" ? obj.year : undefined,
status: typeof obj.status === "number" ? obj.status : undefined,
country: typeof obj.country === "string" ? obj.country : undefined,
rating: typeof obj.rating === "string" ? obj.rating : undefined,
ratingCount:
typeof obj.rating_count === "number" ? obj.rating_count : undefined,
followCount:
typeof obj.follow_count === "number" ? obj.follow_count : undefined,
userFollowCount:
typeof obj.user_follow_count === "number"
? obj.user_follow_count
: undefined,
contentRating:
typeof obj.content_rating === "string"
? obj.content_rating
: undefined,
demographic:
typeof obj.demographic === "number" ? obj.demographic : undefined,
alternativeTitles: Array.isArray(obj.md_titles)
? (obj.md_titles as Array<{ title: string; lang: string }>)
: [],
mdTitles: Array.isArray(obj.md_titles)
? (obj.md_titles as Array<{ title: string; lang: string }>)
: undefined,
mdComics:
obj.md_comics && typeof obj.md_comics === "object"
? (obj.md_comics as { id: string; title: string; slug: string })
: undefined,
highlight:
typeof obj.highlight === "string" ? obj.highlight : undefined,
source: MangaSource.Comick,
};
})
.filter((item): item is ComickManga => item !== null);
}

/**
* Parse raw detail response into Comick manga detail with validation.
* Extracts comic data and transforms external links format safely.
* @param rawResponse - The raw API response object.
* @returns Parsed manga detail or null if invalid.
* @source
*/
/**
* Extract typed string field from object or return undefined.
* @param obj - The object to extract from.
* @param key - The field key.
* @returns String value or undefined.
* @source
*/
private getOptionalString(
obj: Record<string, unknown>,
key: string,
): string | undefined {
const value = obj[key];
return typeof value === "string" ? value : undefined;
}

/**
* Extract typed number field from object or return undefined.
* @param obj - The object to extract from.
* @param key - The field key.
* @returns Number value or undefined.
* @source
*/
private getOptionalNumber(
obj: Record<string, unknown>,
key: string,
): number | undefined {
const value = obj[key];
return typeof value === "number" ? value : undefined;
}

/**
* Extract typed boolean field from object or return undefined.
* @param obj - The object to extract from.
* @param key - The field key.
* @returns Boolean value or undefined.
* @source
*/
private getOptionalBoolean(
obj: Record<string, unknown>,
key: string,
): boolean | undefined {
const value = obj[key];
return typeof value === "boolean" ? value : undefined;
}

/**
* Extract and validate array field from object or return empty array.
* @param obj - The object to extract from.
* @param key - The field key.
* @returns Typed array or empty array.
* @source
*/
private getArrayOrEmpty<T>(obj: Record<string, unknown>, key: string): T[] {
const value = obj[key];
return Array.isArray(value) ? (value as T[]) : [];
}

/**
* Extract and validate object field from object or return undefined.
* @param obj - The object to extract from.
* @param key - The field key.
* @returns Typed object or undefined.
* @source
*/
private getOptionalObject<T extends Record<string, unknown>>(
obj: Record<string, unknown>,
key: string,
): T | undefined {
const value = obj[key];
return value && typeof value === "object" ? (value as T) : undefined;
}

/**
* Build comic nested object from Comick data with type validation.
* @param comicObj - The comic object from API response.
* @returns Parsed comic object.
* @source
*/
private buildComicObject(comicObj: Record<string, unknown>) {
return {
id: String(comicObj.id),
title: String(comicObj.title),
slug: String(comicObj.slug),
desc: this.getOptionalString(comicObj, "desc"),
status: this.getOptionalNumber(comicObj, "status"),
year: this.getOptionalNumber(comicObj, "year"),
country: this.getOptionalString(comicObj, "country"),
createdAt: this.getOptionalString(comicObj, "created_at"),
updatedAt: this.getOptionalString(comicObj, "updated_at"),
demographic: this.getOptionalNumber(comicObj, "demographic"),
hentai: this.getOptionalBoolean(comicObj, "hentai"),
contentRating: this.getOptionalString(comicObj, "content_rating"),
muComics: this.getOptionalObject<{
id: string;
title: string;
slug: string;
}>(comicObj, "mu_comics"),
mdComics: this.getOptionalObject<{
id: string;
title: string;
slug: string;
}>(comicObj, "md_comics"),
authors: this.getArrayOrEmpty<{
id: string;
name: string;
slug: string;
}>(comicObj, "authors"),
artists: this.getArrayOrEmpty<{
id: string;
name: string;
slug: string;
}>(comicObj, "artists"),
genres: this.getArrayOrEmpty<{
id: string;
name: string;
slug: string;
}>(comicObj, "genres"),
mdTitles: this.getArrayOrEmpty<{
title: string;
lang: string;
}>(comicObj, "md_titles"),
links: this.parseLinksObject(
comicObj.links as Record<string, unknown> | undefined,
),
};
}

/**
* Parse raw detail response into Comick manga detail with validation.
* Extracts comic data and transforms external links format safely.
* @param rawResponse - The raw API response object.
* @returns Parsed manga detail or null if invalid.
* @source
*/
protected parseDetailResponse(
rawResponse: unknown,
): ComickMangaDetail | null {
if (
!rawResponse ||
typeof rawResponse !== "object" ||
!("comic" in rawResponse)
) {
return null;
}

const response = rawResponse as Record<string, unknown>;
const comic = response.comic;

if (
!comic ||
typeof comic !== "object" ||
!("id" in comic) ||
!("title" in comic) ||
!("slug" in comic)
) {
console.warn("[Comick] Invalid comic detail structure", comic);
return null;
}

const comicObj = comic as Record<string, unknown>;

return {
id: String(comicObj.id),
title: String(comicObj.title),
slug: String(comicObj.slug),
description:
typeof comicObj.desc === "string" ? comicObj.desc : undefined,
status: typeof comicObj.status === "number" ? comicObj.status : undefined,
year: typeof comicObj.year === "number" ? comicObj.year : undefined,
country:
typeof comicObj.country === "string" ? comicObj.country : undefined,
createdAt:
typeof comicObj.created_at === "string"
? comicObj.created_at
: undefined,
updatedAt:
typeof comicObj.updated_at === "string"
? comicObj.updated_at
: undefined,
authors: Array.isArray(comicObj.authors)
? (comicObj.authors as Array<{
id: string;
name: string;
slug: string;
}>)
: [],
artists: Array.isArray(comicObj.artists)
? (comicObj.artists as Array<{
id: string;
name: string;
slug: string;
}>)
: [],
genres: Array.isArray(comicObj.genres)
? (comicObj.genres as Array<{ id: string; name: string; slug: string }>)
: [],
alternativeTitles: Array.isArray(comicObj.md_titles)
? (comicObj.md_titles as Array<{ title: string; lang: string }>)
: [],
externalLinks:
comicObj.links && typeof comicObj.links === "object"
? this.parseExternalLinks(comicObj.links as Record<string, unknown>)
: {},
source: MangaSource.Comick,
comic: this.buildComicObject(comicObj),
langList: Array.isArray(response.langList)
? (response.langList as string[])
: undefined,
};
}

/**
* Parse external links object into typed structure.
* @param links - Raw links object.
* @returns Parsed external links with platform identifiers.
* @source
*/
private parseExternalLinks(
links: Record<string, unknown>,
): Record<string, string> {
const result: Record<string, string> = {};
if (typeof links.al === "string") result.anilist = links.al;
if (typeof links.mal === "string") result.myAnimeList = links.mal;
if (typeof links.mu === "string") result.mangaUpdates = links.mu;
// Include other platform links
for (const [key, value] of Object.entries(links)) {
if (!["al", "mal", "mu"].includes(key) && typeof value === "string") {
result[key] = value;
}
}
return result;
}

/**
* Parse raw links object into typed structure.
* @param links - Raw links object or undefined.
* @returns Parsed links with platform abbreviations.
* @source
*/
private parseLinksObject(
links: Record<string, unknown> | undefined,
): Record<string, string | undefined> {
if (!links) return {};
const result: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(links)) {
result[key] = typeof value === "string" ? value : undefined;
}
return result;
}

/**
* Extract AniList ID from Comick manga detail.
* Parses the 'al' field from external links section.
* @param detail - The Comick manga detail object.
* @returns The AniList ID as a number or null if not found.
* @source
*/
protected extractAniListIdFromDetail(
detail: ComickMangaDetail,
): number | null {
const links = detail.comic?.links;
if (!links || typeof links !== "object") return null;

const anilistId = links.al;
if (!anilistId) return null;

const parsedAnilistId = Number.parseInt(anilistId, 10);
return Number.isNaN(parsedAnilistId) ? null : parsedAnilistId;
}
}

Hierarchy (View Summary)

Constructors

Methods

  • Extract AniList ID from a manga entry by fetching its full details.

    Parameters

    • manga: ComickManga

      The manga entry to extract AniList ID from.

    Returns Promise<null | number>

    Promise resolving to AniList ID or null if not found.

      public async extractAniListId(manga: TMangaEntry): Promise<number | null> {
    try {
    console.debug(
    `[MangaSourceBase] 🔗 ${this.config.name}: Extracting AniList ID for "${manga.title}"`,
    );

    const detail = await this.getMangaDetail(manga.slug);
    if (!detail) {
    console.debug(
    `[MangaSourceBase] 🔗 No detail data found for ${this.config.name} manga: ${manga.title}`,
    );
    return null;
    }

    const anilistId = this.extractAniListIdFromDetail(detail);
    if (anilistId) {
    console.debug(
    `[MangaSourceBase] 🎯 Found AniList ID ${anilistId} for ${this.config.name} manga: ${manga.title}`,
    );
    return anilistId;
    }

    console.debug(
    `[MangaSourceBase] 🔗 No AniList ID found for ${this.config.name} manga: ${manga.title}`,
    );
    return null;
    } catch (error) {
    console.error(
    `[MangaSourceBase] ❌ Failed to extract AniList ID for ${this.config.name} manga ${manga.title}:`,
    error,
    );
    return null;
    }
    }
  • Search for manga on this source and enrich results with AniList data. Fetches source results, extracts AniList IDs, and merges AniList data.

    Parameters

    • query: string

      The search query string.

    • accessToken: string

      AniList OAuth access token for fetching manga details.

    • limit: number = 1

      Maximum number of results to return (default: 1).

    Returns Promise<EnhancedAniListManga[]>

    Promise resolving to enhanced AniList manga entries with source info.

      public async searchAndGetAniListManga(
    query: string,
    accessToken: string,
    limit: number = 1,
    ): Promise<EnhancedAniListManga[]> {
    try {
    console.info(
    `[MangaSourceBase] 🔍 Starting ${this.config.name} search for "${query}" with limit ${limit}`,
    );

    // Search on this source
    const sourceResults = await this.searchManga(query, limit);
    if (!sourceResults?.length) {
    console.debug(
    `[MangaSourceBase] 📦 No ${this.config.name} results found for "${query}"`,
    );
    return [];
    }

    console.debug(
    `[MangaSourceBase] 📦 Found ${sourceResults.length} ${this.config.name} results, extracting AniList IDs...`,
    );

    const anilistIds: number[] = [];
    const sourceMap = new Map<number, TMangaEntry>();

    for (const sourceManga of sourceResults) {
    const anilistId = await this.extractAniListId(sourceManga);
    if (!anilistId) continue;
    anilistIds.push(anilistId);
    sourceMap.set(anilistId, sourceManga);
    }

    if (!anilistIds.length) {
    console.debug(
    `[MangaSourceBase] 🔗 No AniList links found in ${this.config.name} results for "${query}"`,
    );
    return [];
    }

    console.info(
    `[MangaSourceBase] 🎯 Found ${anilistIds.length} AniList IDs from ${this.config.name}: [${anilistIds.join(", ")}]`,
    );

    const anilistManga = await getMangaByIds(anilistIds, accessToken);
    if (!anilistManga?.length) {
    console.warn(
    `[MangaSourceBase] ❌ Failed to fetch AniList manga for IDs: [${anilistIds.join(", ")}]`,
    );
    return [];
    }

    // Enhance AniList manga with source info
    const enhancedManga: EnhancedAniListManga[] = anilistManga.map(
    (manga) => {
    const sourceInfo = sourceMap.get(manga.id);

    return {
    ...manga,
    sourceInfo: sourceInfo
    ? {
    title: sourceInfo.title,
    slug: sourceInfo.slug,
    sourceId: sourceInfo.id,
    source: this.config.source,
    isFoundViaAlternativeSearch: true,
    }
    : undefined,
    };
    },
    );

    console.info(
    `[MangaSourceBase] ✅ Successfully enhanced ${enhancedManga.length} AniList manga with ${this.config.name} source info`,
    );
    return enhancedManga;
    } catch (error) {
    console.error(
    `[MangaSourceBase] ❌ ${this.config.name} search and AniList fetch failed for "${query}":`,
    error,
    );
    return [];
    }
    }
  • Clear cache entries for specific search queries. Removes all cache entries matching the query pattern across different limits.

    Parameters

    • queries: string[]

      Array of search queries to clear from cache.

    Returns number

    Number of cache entries cleared.

      public clearCache(queries: string[]): number {
    let clearedCount = 0;

    for (const query of queries) {
    // Clear cache entries matching query (different limits may exist)
    const keysToDelete = Object.keys(this.cache).filter((key) =>
    key.startsWith(`search:${query.toLowerCase()}:`),
    );

    for (const key of keysToDelete) {
    delete this.cache[key];
    clearedCount++;
    }
    }

    console.info(
    `[MangaSourceBase] 🧹 Cleared ${clearedCount} ${this.config.name} cache entries`,
    );
    return clearedCount;
    }
  • Get cache status and statistics for debugging.

    Returns {
        source: string;
        totalEntries: number;
        activeEntries: number;
        expiredEntries: number;
    }

    Object with total entries, active entries, and expired entries counts.

      public getCacheStatus() {
    const totalEntries = Object.keys(this.cache).length;
    const expiredEntries = Object.keys(this.cache).filter(
    (key) => Date.now() - this.cache[key].timestamp > this.cacheExpiryMs,
    ).length;

    return {
    source: this.config.name,
    totalEntries,
    activeEntries: totalEntries - expiredEntries,
    expiredEntries,
    };
    }
  • Search for manga on Comick API via IPC (to avoid CORS issues). Results are cached for 30 minutes by default.

    Parameters

    • query: string

      The search query string.

    • limit: number = 10

      Maximum number of results to return (default: 10).

    Returns Promise<ComickManga[]>

    Promise resolving to array of Comick manga entries.

      public async searchManga(
    query: string,
    limit: number = 10,
    ): Promise<ComickManga[]> {
    return withGroupAsync(`[Comick] Search: "${query}"`, async () => {
    // Check cache first
    const cacheKey = `search:${query.toLowerCase()}:${limit}`;
    const cached = this.getCachedData<ComickManga[]>(cacheKey);
    if (cached) {
    console.debug(`[Comick] 📦 Using cached results for "${query}"`);
    return cached;
    }

    console.info(
    `[Comick] 🔍 Searching Comick for: "${query}" (limit: ${limit})`,
    );

    try {
    // Use generic manga source API to call main process via IPC instead of direct fetch (CORS)
    const data = (await globalThis.electronAPI.mangaSource.search(
    MangaSource.Comick,
    query,
    limit,
    )) as ComickManga[];

    const results = this.parseSearchResponse(data);
    this.setCachedData(cacheKey, results);

    console.info(
    `[Comick] ✅ Comick search found ${results?.length || 0} results for "${query}"`,
    );
    return results;
    } catch (error) {
    console.error(
    `[Comick] ❌ Comick search failed for "${query}":`,
    error,
    );
    return [];
    }
    });
    } /**
  • Get detailed information about a specific manga. Must be implemented by each subclass with source-specific logic.

    Parameters

    • slug: string

      The manga identifier or slug.

    Returns Promise<null | ComickMangaDetail>

    Promise resolving to manga detail or null if not found.

      public async getMangaDetail(slug: string): Promise<ComickMangaDetail | null> {
    return withGroupAsync(`[Comick] Detail: "${slug}"`, async () => {
    console.debug(`[Comick] 📖 Getting Comick manga details for: ${slug}`);

    try {
    // Use generic manga source API to call main process via IPC instead of direct fetch (CORS)
    const rawData = await globalThis.electronAPI.mangaSource.getMangaDetail(
    MangaSource.Comick,
    slug,
    );
    const detail = this.parseDetailResponse(rawData);
    if (detail) {
    console.info(`[Comick] ✅ Retrieved details for manga ${slug}`);
    }
    return detail;
    } catch (error) {
    console.error(
    `[Comick] ❌ Failed to get Comick manga details for ${slug}:`,
    error,
    );
    return null;
    }
    });
    }