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,
|
DbMarketPayload,
|
||||||
BasketballTeamStats,
|
BasketballTeamStats,
|
||||||
} from "./feeder.types";
|
} from "./feeder.types";
|
||||||
import { ImageUtils } from "../../common/utils/image.util";
|
|
||||||
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
import { deriveStoredMatchStatus } from "../../common/utils/match-status.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -164,24 +163,6 @@ export class FeederPersistenceService {
|
|||||||
oddsArray: DbMarketPayload[],
|
oddsArray: DbMarketPayload[],
|
||||||
officialsData: MatchOfficial[],
|
officialsData: MatchOfficial[],
|
||||||
): Promise<boolean> {
|
): 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 = [
|
const teamsToUpsert = [
|
||||||
{
|
{
|
||||||
id: homeTeamId,
|
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
|
// DATABASE TRANSACTION
|
||||||
try {
|
try {
|
||||||
await this.prisma.$transaction(
|
await this.prisma.$transaction(
|
||||||
@@ -264,7 +231,6 @@ export class FeederPersistenceService {
|
|||||||
countryId: countryId,
|
countryId: countryId,
|
||||||
sport: sport,
|
sport: sport,
|
||||||
competitionSlug: league.competitionSlug,
|
competitionSlug: league.competitionSlug,
|
||||||
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
|
|
||||||
} as any,
|
} as any,
|
||||||
});
|
});
|
||||||
if (league.sortOrder !== undefined) {
|
if (league.sortOrder !== undefined) {
|
||||||
@@ -291,10 +257,7 @@ export class FeederPersistenceService {
|
|||||||
|
|
||||||
if (teamsToCreate.length > 0) {
|
if (teamsToCreate.length > 0) {
|
||||||
await tx.team.createMany({
|
await tx.team.createMany({
|
||||||
data: teamsToCreate.map((t) => ({
|
data: teamsToCreate,
|
||||||
...t,
|
|
||||||
logoUrl: `/uploads/teams/${t.id}.png`,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -304,7 +267,6 @@ export class FeederPersistenceService {
|
|||||||
where: { id: team.id },
|
where: { id: team.id },
|
||||||
data: {
|
data: {
|
||||||
name: team.name,
|
name: team.name,
|
||||||
logoUrl: `/uploads/teams/${team.id}.png`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -614,9 +576,6 @@ export class FeederPersistenceService {
|
|||||||
{ maxWait: 40000, timeout: 40000 },
|
{ maxWait: 40000, timeout: 40000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// WAIT FOR IMAGES AFTER TRANSACTION
|
|
||||||
await Promise.allSettled(imageDownloads);
|
|
||||||
|
|
||||||
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
|
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../database/prisma.service";
|
import { PrismaService } from "../../database/prisma.service";
|
||||||
import { Sport } from "@prisma/client";
|
import { Sport } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
countryFlagUrl,
|
||||||
|
teamLogoUrl,
|
||||||
|
} from "../../common/utils/image-url.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LeaguesService {
|
export class LeaguesService {
|
||||||
@@ -12,19 +16,21 @@ export class LeaguesService {
|
|||||||
* Get all countries
|
* Get all countries
|
||||||
*/
|
*/
|
||||||
async findAllCountries() {
|
async findAllCountries() {
|
||||||
return this.prisma.country.findMany({
|
const countries = await this.prisma.country.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
return countries.map((c) => ({ ...c, flag: countryFlagUrl(c.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get country by ID
|
* Get country by ID
|
||||||
*/
|
*/
|
||||||
async findCountryById(id: string) {
|
async findCountryById(id: string) {
|
||||||
return this.prisma.country.findUnique({
|
const country = await this.prisma.country.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { leagues: true },
|
include: { leagues: true },
|
||||||
});
|
});
|
||||||
|
return country ? { ...country, flag: countryFlagUrl(country.id) } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +72,7 @@ export class LeaguesService {
|
|||||||
* Get all teams
|
* Get all teams
|
||||||
*/
|
*/
|
||||||
async findAllTeams(sport?: Sport, search?: string) {
|
async findAllTeams(sport?: Sport, search?: string) {
|
||||||
return this.prisma.team.findMany({
|
const teams = await this.prisma.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(sport ? { sport } : {}),
|
...(sport ? { sport } : {}),
|
||||||
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
|
...(search ? { name: { contains: search, mode: "insensitive" } } : {}),
|
||||||
@@ -74,28 +80,31 @@ export class LeaguesService {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get team by ID
|
* Get team by ID
|
||||||
*/
|
*/
|
||||||
async findTeamById(id: string) {
|
async findTeamById(id: string) {
|
||||||
return this.prisma.team.findUnique({
|
const team = await this.prisma.team.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
return team ? { ...team, logo: teamLogoUrl(team.id) } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search teams by name
|
* Search teams by name
|
||||||
*/
|
*/
|
||||||
async searchTeams(name: string, sport?: Sport) {
|
async searchTeams(name: string, sport?: Sport) {
|
||||||
return this.prisma.team.findMany({
|
const teams = await this.prisma.team.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { contains: name, mode: "insensitive" },
|
name: { contains: name, mode: "insensitive" },
|
||||||
...(sport ? { sport } : {}),
|
...(sport ? { sport } : {}),
|
||||||
},
|
},
|
||||||
take: 20,
|
take: 20,
|
||||||
});
|
});
|
||||||
|
return teams.map((t) => ({ ...t, logo: teamLogoUrl(t.id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,13 +170,9 @@ export class LeaguesService {
|
|||||||
status: m.status,
|
status: m.status,
|
||||||
state: m.state,
|
state: m.state,
|
||||||
homeTeamName: m.homeTeam?.name,
|
homeTeamName: m.homeTeam?.name,
|
||||||
homeTeamLogo: m.homeTeamId
|
homeTeamLogo: teamLogoUrl(m.homeTeamId) ?? null,
|
||||||
? `https://file.mackolikfeeds.com/teams/${m.homeTeamId}`
|
|
||||||
: null,
|
|
||||||
awayTeamName: m.awayTeam?.name,
|
awayTeamName: m.awayTeam?.name,
|
||||||
awayTeamLogo: m.awayTeamId
|
awayTeamLogo: teamLogoUrl(m.awayTeamId) ?? null,
|
||||||
? `https://file.mackolikfeeds.com/teams/${m.awayTeamId}`
|
|
||||||
: null,
|
|
||||||
leagueName: m.league?.name,
|
leagueName: m.league?.name,
|
||||||
countryName: m.league?.country?.name,
|
countryName: m.league?.country?.name,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import {
|
|||||||
LIVE_STATUS_VALUES_FOR_DB,
|
LIVE_STATUS_VALUES_FOR_DB,
|
||||||
getDisplayMatchStatus,
|
getDisplayMatchStatus,
|
||||||
} from "../../common/utils/match-status.util";
|
} from "../../common/utils/match-status.util";
|
||||||
|
import {
|
||||||
|
countryFlagUrl,
|
||||||
|
teamLogoUrl,
|
||||||
|
} from "../../common/utils/image-url.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MatchesService {
|
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 {
|
private getCountryFlagUrl(countryId?: string | null): string | undefined {
|
||||||
if (!countryId) return undefined;
|
return countryFlagUrl(countryId);
|
||||||
return `https://file.mackolikfeeds.com/areas/${countryId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate URL for the team logo served from local uploads
|
|
||||||
*/
|
|
||||||
private getTeamLogoUrl(teamId?: string | null): string | undefined {
|
private getTeamLogoUrl(teamId?: string | null): string | undefined {
|
||||||
if (!teamId) return undefined;
|
return teamLogoUrl(teamId);
|
||||||
return `https://file.mackolikfeeds.com/teams/${teamId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveFilter(): Prisma.LiveMatchWhereInput {
|
private getLiveFilter(): Prisma.LiveMatchWhereInput {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import * as fs from "fs";
|
|||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { ImageRendererService } from "./image-renderer.service";
|
import { ImageRendererService } from "./image-renderer.service";
|
||||||
|
import {
|
||||||
|
competitionLogoUrl,
|
||||||
|
countryFlagUrl,
|
||||||
|
teamLogoUrl,
|
||||||
|
} from "../../common/utils/image-url.util";
|
||||||
import { CaptionGeneratorService } from "./caption-generator.service";
|
import { CaptionGeneratorService } from "./caption-generator.service";
|
||||||
import { TwitterService } from "./twitter.service";
|
import { TwitterService } from "./twitter.service";
|
||||||
import { MetaService } from "./meta.service";
|
import { MetaService } from "./meta.service";
|
||||||
@@ -387,15 +392,15 @@ export class SocialPosterService {
|
|||||||
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
|
match.homeTeam?.name || prediction.match_info?.home_team || "Home",
|
||||||
awayTeam:
|
awayTeam:
|
||||||
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
|
match.awayTeam?.name || prediction.match_info?.away_team || "Away",
|
||||||
homeLogo: this.resolveLogoUrl(match.homeTeam?.logoUrl || ""),
|
homeLogo: teamLogoUrl(match.homeTeamId) ?? "",
|
||||||
awayLogo: this.resolveLogoUrl(match.awayTeam?.logoUrl || ""),
|
awayLogo: teamLogoUrl(match.awayTeamId) ?? "",
|
||||||
leagueName: match.league?.name || prediction.match_info?.league || "",
|
leagueName: match.league?.name || prediction.match_info?.league || "",
|
||||||
leagueLogo: this.resolveLogoUrl(match.league?.logoUrl || ""),
|
leagueLogo: competitionLogoUrl(match.leagueId) ?? "",
|
||||||
countryName:
|
countryName:
|
||||||
match.league?.country?.name ||
|
match.league?.country?.name ||
|
||||||
prediction.match_info?.country ||
|
prediction.match_info?.country ||
|
||||||
this.inferCountryName(match.league?.name || ""),
|
this.inferCountryName(match.league?.name || ""),
|
||||||
countryFlag: this.resolveLogoUrl(match.league?.country?.flagUrl || ""),
|
countryFlag: countryFlagUrl(match.league?.country?.id) ?? "",
|
||||||
matchDate,
|
matchDate,
|
||||||
htScore,
|
htScore,
|
||||||
ftScore,
|
ftScore,
|
||||||
@@ -488,22 +493,6 @@ export class SocialPosterService {
|
|||||||
: "football";
|
: "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 {
|
private formatMatchDate(mstUtc: number | bigint): string {
|
||||||
const d = new Date(Number(mstUtc));
|
const d = new Date(Number(mstUtc));
|
||||||
const months = [
|
const months = [
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"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,
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": 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