diff --git a/scripts/warm-image-cache.sh b/scripts/warm-image-cache.sh new file mode 100755 index 0000000..d4af704 --- /dev/null +++ b/scripts/warm-image-cache.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Warms the R2 image bucket by requesting every known team / competition / +# country image once through the image-proxy Worker, which mirrors each one +# into R2 (see workers/image-proxy). +# +# Run on the production server (needs docker access to iddaai-postgres): +# ./warm-image-cache.sh https://files.example.com +set -euo pipefail + +BASE_URL="${1:?Usage: $0 }" +BASE_URL="${BASE_URL%/}" +PSQL=(docker exec iddaai-postgres psql -U iddaai_user -d iddaai_db -At -c) + +{ + "${PSQL[@]}" "SELECT 'teams/' || id FROM teams" + "${PSQL[@]}" "SELECT 'competitions/' || id FROM leagues" + "${PSQL[@]}" "SELECT 'areas/' || id FROM countries" +} | xargs -P 8 -I{} curl -sS -o /dev/null -w "%{http_code}\n" "$BASE_URL/{}" \ + | sort | uniq -c \ + | awk '{printf "HTTP %s: %s istek\n", $2, $1}' diff --git a/src/common/utils/image-url.util.ts b/src/common/utils/image-url.util.ts new file mode 100644 index 0000000..923978d --- /dev/null +++ b/src/common/utils/image-url.util.ts @@ -0,0 +1,35 @@ +/** + * Central builder for team / competition / country image URLs. + * + * Images are served from a Cloudflare R2 bucket fronted by the + * `workers/image-proxy` Worker, which lazily mirrors each image from the + * upstream provider (file.mackolikfeeds.com) into R2 on first request. + * + * Set IMAGE_BASE_URL (no trailing slash, e.g. https://files.example.com) + * to serve from the Worker. When unset, URLs point directly at the + * upstream provider so behaviour is unchanged until the bucket is live. + */ +const DEFAULT_IMAGE_BASE_URL = "https://file.mackolikfeeds.com"; + +function imageBaseUrl(): string { + return ( + process.env.IMAGE_BASE_URL?.replace(/\/+$/, "") || DEFAULT_IMAGE_BASE_URL + ); +} + +export function teamLogoUrl(teamId?: string | null): string | undefined { + if (!teamId) return undefined; + return `${imageBaseUrl()}/teams/${teamId}`; +} + +export function competitionLogoUrl( + competitionId?: string | null, +): string | undefined { + if (!competitionId) return undefined; + return `${imageBaseUrl()}/competitions/${competitionId}`; +} + +export function countryFlagUrl(countryId?: string | null): string | undefined { + if (!countryId) return undefined; + return `${imageBaseUrl()}/areas/${countryId}`; +} diff --git a/src/common/utils/image.util.ts b/src/common/utils/image.util.ts deleted file mode 100755 index eec1bb6..0000000 --- a/src/common/utils/image.util.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { existsSync, createWriteStream, mkdirSync } from "fs"; -import { dirname } from "path"; -import axios from "axios"; -import { Logger } from "@nestjs/common"; - -export class ImageUtils { - private static readonly logger = new Logger("ImageUtils"); - - /** - * Downloads an image from a URL and saves it to a local path. - * Skips download if file already exists. - */ - static async downloadImage(url: string, localPath: string): Promise { - try { - // Check if file exists - if (existsSync(localPath)) { - return true; - } - - // Ensure directory exists - const dir = dirname(localPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - // Download - const response = await axios({ - url, - method: "GET", - responseType: "stream", - timeout: 5000, - validateStatus: (status) => status === 200, // Only save if 200 OK - }); - - const writer = createWriteStream(localPath); - - response.data.pipe(writer); - - return new Promise((resolve, reject) => { - writer.on("finish", () => resolve(true)); - writer.on("error", (err) => { - this.logger.warn( - `Failed to write image to ${localPath}: ${err.message}`, - ); - reject(new Error(`Failed to write image to ${localPath}`)); - }); - }); - } catch (error: any) { - // Log warning but don't break the application - // 404s are common for missing logos - if (error.response?.status !== 404) { - this.logger.warn( - `Failed to download image from ${url}: ${error.message}`, - ); - } - return false; - } - } -} diff --git a/src/modules/feeder/feeder-persistence.service.ts b/src/modules/feeder/feeder-persistence.service.ts index 74cd862..7d875fd 100755 --- a/src/modules/feeder/feeder-persistence.service.ts +++ b/src/modules/feeder/feeder-persistence.service.ts @@ -22,7 +22,6 @@ import { DbMarketPayload, BasketballTeamStats, } from "./feeder.types"; -import { ImageUtils } from "../../common/utils/image.util"; import { deriveStoredMatchStatus } from "../../common/utils/match-status.util"; @Injectable() @@ -164,24 +163,6 @@ export class FeederPersistenceService { oddsArray: DbMarketPayload[], officialsData: MatchOfficial[], ): Promise { - // START IMAGE DOWNLOADS (NON-BLOCKING) - const imageDownloads: Promise[] = []; - - const leagueId = this.safeString(league.id); - if (leagueId) { - const logoUrl = `https://file.mackolikfeeds.com/competitions/${leagueId}`; - const localPath = `public/uploads/competitions/${leagueId}.png`; - imageDownloads.push( - ImageUtils.downloadImage(logoUrl, localPath) - .then(() => void 0) - .catch((err) => { - this.logger.error( - `Failed to download league logo ${leagueId}: ${err}`, - ); - }), - ); - } - const teamsToUpsert = [ { id: homeTeamId, @@ -197,20 +178,6 @@ export class FeederPersistenceService { }, ]; - for (const team of teamsToUpsert) { - const teamLogoUrl = `https://file.mackolikfeeds.com/teams/${team.id}`; - const teamLocalPath = `public/uploads/teams/${team.id}.png`; - imageDownloads.push( - ImageUtils.downloadImage(teamLogoUrl, teamLocalPath) - .then(() => void 0) - .catch((err) => { - this.logger.error( - `Failed to download team logo ${team.id}: ${err}`, - ); - }), - ); - } - // DATABASE TRANSACTION try { await this.prisma.$transaction( @@ -264,7 +231,6 @@ export class FeederPersistenceService { countryId: countryId, sport: sport, competitionSlug: league.competitionSlug, - logoUrl: `/uploads/competitions/${finalLeagueId}.png`, } as any, }); if (league.sortOrder !== undefined) { @@ -291,10 +257,7 @@ export class FeederPersistenceService { if (teamsToCreate.length > 0) { await tx.team.createMany({ - data: teamsToCreate.map((t) => ({ - ...t, - logoUrl: `/uploads/teams/${t.id}.png`, - })), + data: teamsToCreate, skipDuplicates: true, }); } @@ -304,7 +267,6 @@ export class FeederPersistenceService { where: { id: team.id }, data: { name: team.name, - logoUrl: `/uploads/teams/${team.id}.png`, }, }); } @@ -614,9 +576,6 @@ export class FeederPersistenceService { { maxWait: 40000, timeout: 40000 }, ); - // WAIT FOR IMAGES AFTER TRANSACTION - await Promise.allSettled(imageDownloads); - this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`); return true; } catch (error: any) { diff --git a/src/modules/leagues/leagues.service.ts b/src/modules/leagues/leagues.service.ts index 9cb23cf..32b1734 100755 --- a/src/modules/leagues/leagues.service.ts +++ b/src/modules/leagues/leagues.service.ts @@ -1,6 +1,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../database/prisma.service"; import { Sport } from "@prisma/client"; +import { + countryFlagUrl, + teamLogoUrl, +} from "../../common/utils/image-url.util"; @Injectable() export class LeaguesService { @@ -12,19 +16,21 @@ export class LeaguesService { * Get all countries */ async findAllCountries() { - return this.prisma.country.findMany({ + const countries = await this.prisma.country.findMany({ orderBy: { name: "asc" }, }); + return countries.map((c) => ({ ...c, flag: countryFlagUrl(c.id) })); } /** * Get country by ID */ async findCountryById(id: string) { - return this.prisma.country.findUnique({ + const country = await this.prisma.country.findUnique({ where: { id }, include: { leagues: true }, }); + return country ? { ...country, flag: countryFlagUrl(country.id) } : null; } /** @@ -66,7 +72,7 @@ export class LeaguesService { * Get all teams */ async findAllTeams(sport?: Sport, search?: string) { - return this.prisma.team.findMany({ + const teams = await this.prisma.team.findMany({ where: { ...(sport ? { sport } : {}), ...(search ? { name: { contains: search, mode: "insensitive" } } : {}), @@ -74,28 +80,31 @@ export class LeaguesService { orderBy: { name: "asc" }, take: 100, }); + return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) })); } /** * Get team by ID */ async findTeamById(id: string) { - return this.prisma.team.findUnique({ + const team = await this.prisma.team.findUnique({ where: { id }, }); + return team ? { ...team, logo: teamLogoUrl(team.id) } : null; } /** * Search teams by name */ async searchTeams(name: string, sport?: Sport) { - return this.prisma.team.findMany({ + const teams = await this.prisma.team.findMany({ where: { name: { contains: name, mode: "insensitive" }, ...(sport ? { sport } : {}), }, take: 20, }); + return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) })); } /** @@ -161,13 +170,9 @@ export class LeaguesService { status: m.status, state: m.state, homeTeamName: m.homeTeam?.name, - homeTeamLogo: m.homeTeamId - ? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}` - : null, + homeTeamLogo: teamLogoUrl(m.homeTeamId) ?? null, awayTeamName: m.awayTeam?.name, - awayTeamLogo: m.awayTeamId - ? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}` - : null, + awayTeamLogo: teamLogoUrl(m.awayTeamId) ?? null, leagueName: m.league?.name, countryName: m.league?.country?.name, })), diff --git a/src/modules/matches/matches.service.ts b/src/modules/matches/matches.service.ts index 39c7d83..5d1e33d 100755 --- a/src/modules/matches/matches.service.ts +++ b/src/modules/matches/matches.service.ts @@ -16,6 +16,10 @@ import { LIVE_STATUS_VALUES_FOR_DB, getDisplayMatchStatus, } from "../../common/utils/match-status.util"; +import { + countryFlagUrl, + teamLogoUrl, +} from "../../common/utils/image-url.util"; @Injectable() export class MatchesService { @@ -98,20 +102,12 @@ export class MatchesService { } } - /** - * Generate URL for the country flag served from Mackolik - */ private getCountryFlagUrl(countryId?: string | null): string | undefined { - if (!countryId) return undefined; - return `https://file.mackolikfeeds.com/areas/${countryId}`; + return countryFlagUrl(countryId); } - /** - * Generate URL for the team logo served from local uploads - */ private getTeamLogoUrl(teamId?: string | null): string | undefined { - if (!teamId) return undefined; - return `https://file.mackolikfeeds.com/teams/${teamId}`; + return teamLogoUrl(teamId); } private getLiveFilter(): Prisma.LiveMatchWhereInput { diff --git a/src/modules/social-poster/social-poster.service.ts b/src/modules/social-poster/social-poster.service.ts index a4352a2..acda2ae 100644 --- a/src/modules/social-poster/social-poster.service.ts +++ b/src/modules/social-poster/social-poster.service.ts @@ -7,6 +7,11 @@ import * as fs from "fs"; import * as path from "path"; import { ImageRendererService } from "./image-renderer.service"; +import { + competitionLogoUrl, + countryFlagUrl, + teamLogoUrl, +} from "../../common/utils/image-url.util"; import { CaptionGeneratorService } from "./caption-generator.service"; import { TwitterService } from "./twitter.service"; import { MetaService } from "./meta.service"; @@ -387,15 +392,15 @@ export class SocialPosterService { match.homeTeam?.name || prediction.match_info?.home_team || "Home", awayTeam: match.awayTeam?.name || prediction.match_info?.away_team || "Away", - homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""), - awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""), + homeLogo: teamLogoUrl(match.homeTeamId) ?? "", + awayLogo: teamLogoUrl(match.awayTeamId) ?? "", leagueName: match.league?.name || prediction.match_info?.league || "", - leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""), + leagueLogo: competitionLogoUrl(match.leagueId) ?? "", countryName: match.league?.country?.name || prediction.match_info?.country || this.inferCountryName(match.league?.name || ""), - countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""), + countryFlag: countryFlagUrl(match.league?.country?.id) ?? "", matchDate, htScore, ftScore, @@ -488,22 +493,6 @@ export class SocialPosterService { : "football"; } - /** - * Convert relative logo paths to full HTTP URLs. - * On the deployed server, logos exist at public/uploads/teams/... - * Locally during dev, we fetch them from the deployed server via APP_BASE_URL. - */ - private resolveLogoUrl(logoUrl: string): string { - if (!logoUrl) return ""; - // Already a full URL - if (logoUrl.startsWith("http")) return logoUrl; - // Relative path → check local first, otherwise make full URL - const localPath = path.join(process.cwd(), "public", logoUrl); - if (fs.existsSync(localPath)) return logoUrl; // Keep relative, renderer reads local - // Not local → prepend base URL for remote fetch - return `${this.appBaseUrl}${logoUrl}`; - } - private formatMatchDate(mstUtc: number | bigint): string { const d = new Date(Number(mstUtc)); const months = [ diff --git a/tsconfig.build.json b/tsconfig.build.json index eff397d..f6d97fd 100755 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "dist-new", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "dist-new", "workers", "**/*spec.ts"] } diff --git a/tsconfig.json b/tsconfig.json index bda7ba4..eb46035 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,5 +22,5 @@ "strictBindCallApply": false, "noFallthroughCasesInSwitch": false }, - "exclude": ["node_modules", "dist", "dist-new", "test"] + "exclude": ["node_modules", "dist", "dist-new", "test", "workers"] }