Executable
+20
@@ -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 <image-base-url>}"
|
||||
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}'
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
// START IMAGE DOWNLOADS (NON-BLOCKING)
|
||||
const imageDownloads: Promise<void>[] = [];
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,5 +22,5 @@
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "dist-new", "test"]
|
||||
"exclude": ["node_modules", "dist", "dist-new", "test", "workers"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user