Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ releases/
*.dmg
*.pkg
*.tar.gz

# Editor / agent scratch dirs
.claude/
.superpowers/
113 changes: 113 additions & 0 deletions Sources/App/Clients/ITunesChartsClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Vapor

// Vapor's existing iTunes clients live under Services/ITunesSearchClient.swift
// etc., but the chart-RSS endpoint serves a different envelope shape and a
// different host path style ("/<country>/rss/topfreeapplications/...") so it
// gets its own thin client. Mirrors the timeout pattern of ITunesSearchClient.

protocol ITunesChartsClientProtocol: Sendable {
func topFree(country: String, genreId: Int, limit: Int) async throws -> [ChartEntry]
}

struct ChartEntry: Sendable, Equatable {
let appStoreId: Int64
let position: Int // 1-indexed
let name: String
}

struct ITunesChartsClient: ITunesChartsClientProtocol {
let client: any Client
let logger: Logger
static let requestTimeoutSeconds: UInt64 = 30

func topFree(country: String, genreId: Int, limit: Int) async throws -> [ChartEntry] {
let cc = country.lowercased()
// RSS feeds for category-scoped charts. The legacy
// /<country>/rss/<chart>/limit=<n>/genre=<id>/json path is the only
// one that supports the genre filter; the newer applemarketingtools
// v2 API drops it.
let url = URI(string:
"https://itunes.apple.com/\(cc)/rss/topfreeapplications/limit=\(limit)/genre=\(genreId)/json"
)

let response: ClientResponse = try await withThrowingTaskGroup(of: ClientResponse.self) { group in
let theClient = client
let theURL = url
let timeoutSeconds = Self.requestTimeoutSeconds
group.addTask { try await theClient.get(theURL) }
group.addTask {
try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000)
throw Abort(.gatewayTimeout, reason: "iTunes charts timed out after \(timeoutSeconds)s")
}
guard let first = try await group.next() else {
throw Abort(.internalServerError, reason: "iTunes charts produced no result")
}
group.cancelAll()
return first
}

guard response.status == .ok else {
logger.error("iTunes charts returned \(response.status) for country=\(cc) genre=\(genreId)")
throw Abort(.badGateway, reason: "iTunes charts failed with \(response.status)")
}

guard let buffer = response.body else { return [] }
return try ITunesChartsClient.parseEntries(from: Data(buffer: buffer))
}

// Pure JSON-to-`[ChartEntry]` parser, exposed for unit testing without a
// live Vapor Client. Marked `static` because it doesn't touch `client`
// or `logger`.
static func parseEntries(from data: Data) throws -> [ChartEntry] {
let envelope = try JSONDecoder().decode(RSSEnvelope.self, from: data)
return envelope.feed.entry?.enumerated().compactMap { (idx, entry) -> ChartEntry? in
guard let idStr = entry.id.attributes.imId, let appStoreId = Int64(idStr) else {
return nil
}
return ChartEntry(
appStoreId: appStoreId,
position: idx + 1,
name: entry.imName.label
)
} ?? []
}
}

// MARK: - RSS envelope (Apple's "iTunes RSS feed in JSON form" shape).
//
// The endpoint returns either an array of entries (when the chart has
// results) or omits the `entry` key entirely (for empty / unknown genres).
// `entry` is therefore optional.
private struct RSSEnvelope: Decodable {
let feed: RSSFeed
}

private struct RSSFeed: Decodable {
let entry: [RSSEntry]?
}

private struct RSSEntry: Decodable {
let imName: ImName
let id: RSSEntryId

enum CodingKeys: String, CodingKey {
case imName = "im:name"
case id
}

struct ImName: Decodable {
let label: String
}

struct RSSEntryId: Decodable {
let attributes: Attributes
struct Attributes: Decodable {
// Apple uses dotted/colon keys ("im:id") which Swift cannot
// express as a property name; decode via a custom CodingKey.
let imId: String?
enum CodingKeys: String, CodingKey {
case imId = "im:id"
}
}
}
}
29 changes: 29 additions & 0 deletions Sources/App/Composition/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ extension Request {
QueueStatusService(db: db)
}

func chartTrackerService() -> any ChartTrackerServiceProtocol {
ChartTrackerService(
db: db,
chartsClient: ITunesChartsClient(client: client, logger: logger),
lookupClient: ITunesLookupClient(client: client),
logger: logger
)
}

func availabilityProber() -> any AvailabilityProberProtocol {
AvailabilityProber(
db: db,
lookupClient: ITunesLookupClient(client: client),
logger: logger
)
}

func versionService() -> any VersionServiceProtocol {
VersionService(
client: client,
Expand Down Expand Up @@ -126,4 +143,16 @@ extension Application {
)
}
}

var chartTrackerServiceFactory: @Sendable (QueueContext) -> any ChartTrackerServiceProtocol {
{ context in
let app = context.application
return ChartTrackerService(
db: app.db,
chartsClient: ITunesChartsClient(client: app.client, logger: context.logger),
lookupClient: ITunesLookupClient(client: app.client),
logger: context.logger
)
}
}
}
17 changes: 16 additions & 1 deletion Sources/App/Controllers/AppsController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,25 @@ struct AppsController: RouteCollection {

@Sendable func create(req: Request) async throws -> WatchedApp {
let payload = try req.content.decode(CreatePayload.self)
return try await req.appService().create(
let app = try await req.appService().create(
appStoreId: payload.appStoreId,
lookupCountry: payload.lookupCountry ?? "us"
)
// Kick off the 175-storefront availability probe in the background so
// the chart-watchdog has a narrowed sweep target on its next pass.
// The HTTP response shouldn't wait on this — it can take ~60s.
if let appID = app.id {
let prober = req.availabilityProber()
let logger = req.logger
Task.detached {
do {
_ = try await prober.probe(watchedAppID: appID)
} catch {
logger.error("Initial availability probe failed for app=\(appID): \(error)")
}
}
}
return app
}

@Sendable func delete(req: Request) async throws -> HTTPStatus {
Expand Down
144 changes: 144 additions & 0 deletions Sources/App/Controllers/ChartsController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Fluent
import Foundation
import Vapor

// HTTP surface for the chart-position watchdog.
// GET /chart-positions → currently-charted snapshot rows
// GET /chart-events?since=&limit= → activity feed for the SPA
// POST /charts/refresh → kick off ChartTrackerService now
// POST /apps/:id/availability/refresh → re-probe a single app's storefronts
struct ChartsController: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.get("chart-positions", use: chartPositions)
routes.get("chart-events", use: chartEvents)
routes.post("charts", "refresh", use: refreshNow)
routes.post("apps", ":id", "availability", "refresh", use: refreshAvailability)
}

// MARK: - DTOs (kept here rather than DomainTypes.swift because they're
// the wire format for one feature; nothing else consumes them.)

struct ChartPositionDTO: Content {
let appId: UUID
let appName: String
let country: String
let chartType: String
let genreId: Int
let position: Int
let observedAt: Date
}

struct ChartEventDTO: Content {
let id: UUID
let appId: UUID
let appName: String
let country: String
let chartType: String
let genreId: Int
let kind: String
let position: Int?
let prevPosition: Int?
let createdAt: Date
}

struct RefreshAcceptedDTO: Content { let queued: Bool }

// MARK: - Handlers

@Sendable func chartPositions(req: Request) async throws -> [ChartPositionDTO] {
// Snapshot rows with a non-null position == "currently charted".
let snaps = try await ChartPositionSnapshot.query(on: req.db)
.filter(\.$position != nil)
.with(\.$watchedApp)
.all()

return snaps.compactMap { snap -> ChartPositionDTO? in
guard
let appID = snap.watchedApp.id,
let position = snap.position
else { return nil }
return ChartPositionDTO(
appId: appID,
appName: snap.watchedApp.name,
country: snap.country,
chartType: snap.chartType,
genreId: snap.genreId,
position: position,
observedAt: snap.observedAt
)
}
}

@Sendable func chartEvents(req: Request) async throws -> [ChartEventDTO] {
let sinceStr = try? req.query.get(String.self, at: "since")
let limit = (try? req.query.get(Int.self, at: "limit")).map { min(max($0, 1), 200) } ?? 50

var q = ChartEvent.query(on: req.db).with(\.$watchedApp)
if let s = sinceStr {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let since = formatter.date(from: s)
?? ISO8601DateFormatter().date(from: s)
if let since {
q = q.filter(\.$createdAt > since)
}
}

let events = try await q
.sort(\.$createdAt, .descending)
.range(..<limit)
.all()

return events.compactMap { ev -> ChartEventDTO? in
guard let evID = ev.id, let appID = ev.watchedApp.id else { return nil }
return ChartEventDTO(
id: evID,
appId: appID,
appName: ev.watchedApp.name,
country: ev.country,
chartType: ev.chartType,
genreId: ev.genreId,
kind: ev.kind,
position: ev.position,
prevPosition: ev.prevPosition,
createdAt: ev.createdAt
)
}
}

@Sendable func refreshNow(req: Request) async throws -> Response {
// Detached task so the HTTP response returns immediately. The job
// can take tens of seconds for a few apps × 30 countries; tying the
// request lifetime to it would block the SPA on the "Check now" click.
let service = req.chartTrackerService()
let logger = req.logger
Task.detached {
do {
_ = try await service.refreshAll(now: Date())
} catch {
logger.error("Manual chart refresh failed: \(error)")
}
}
let response = Response(status: .accepted)
try response.content.encode(RefreshAcceptedDTO(queued: true))
return response
}

@Sendable func refreshAvailability(req: Request) async throws -> Response {
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest, reason: "Invalid app id")
}
let prober = req.availabilityProber()
let logger = req.logger
Task.detached {
do {
_ = try await prober.probe(watchedAppID: id)
} catch {
logger.error("Availability probe failed for app=\(id): \(error)")
}
}
let response = Response(status: .accepted)
try response.content.encode(RefreshAcceptedDTO(queued: true))
return response
}
}
4 changes: 4 additions & 0 deletions Sources/App/Domain/DomainTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ struct LookupResultApp: Codable, Sendable, Equatable {
let bundleId: String
let trackName: String
let artworkUrl100: String?
// Apple's "Primary Category" id (e.g. 6017 = Education). Used by the
// chart-tracking watchdog to pick the right RSS feed per app. Optional
// because not every iTunes lookup variant returns it consistently.
let primaryGenreId: Int?
}

struct DashboardRow: Codable, Sendable, Equatable {
Expand Down
23 changes: 23 additions & 0 deletions Sources/App/Jobs/RefreshChartsScheduler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation
import Queues
import Vapor

// Daily chart-watchdog pass. Mirrors DailyRefreshScheduler's shape: the
// scheduler runs the work inline rather than enqueueing per-app jobs because
// (a) ChartTrackerService already does its own bounded concurrency across
// countries and (b) we only have a handful of watched apps in practice.
//
// Cadence is configured in configure.swift to 04:00 UTC — a few hours after
// Apple's PT-midnight chart refresh window so the RSS feeds have settled.
struct RefreshChartsScheduler: AsyncScheduledJob {
func run(context: QueueContext) async throws {
let service = context.application.chartTrackerServiceFactory(context)
let summary = try await service.refreshAll(now: Date())
context.logger.info(
"""
RefreshChartsScheduler done: \
apps=\(summary.appsProcessed) charts=\(summary.chartsFetched) events=\(summary.eventsEmitted)
"""
)
}
}
Loading
Loading