Skip to content

Commit

Permalink
feat(api): poll listens less often if user is inactive
Browse files Browse the repository at this point in the history
To save on Spotify API requests we have two different classes of
polling intervals:

- all users are polled at least every 10 minutes, this is a safe interval
  and no listens will be ever missed
- if a user listened to a song within the last 60 minutes, we poll every
  minute to ensure that the UI shows new listens immediately
  • Loading branch information
apricote committed Mar 12, 2023
1 parent b9f92bb commit 14478a5
Show file tree
Hide file tree
Showing 3 changed files with 59 additions and 6 deletions.
10 changes: 10 additions & 0 deletions src/listens/listens.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ export class ListensService {
});
}

async getMostRecentListenPerUser(): Promise<Listen[]> {
return this.listenRepository
.createQueryBuilder("listen")
.leftJoinAndSelect("listen.user", "user")
.distinctOn(["user.id"])
.orderBy({ "user.id": "ASC", "listen.playedAt": "DESC" })
.limit(1)
.getMany();
}

getScopedQueryBuilder(): ListenScopes {
return this.listenRepository.scoped;
}
Expand Down
32 changes: 28 additions & 4 deletions src/sources/scheduler.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
UpdateSpotifyLibraryJob,
} from "./jobs";
import { JobService } from "@apricote/nest-pg-boss";
import { Span } from "nestjs-otel";

@Injectable()
export class SchedulerService implements OnApplicationBootstrap {
Expand All @@ -35,15 +36,38 @@ export class SchedulerService implements OnApplicationBootstrap {
await this.superviseImportJobsJobService.schedule("*/1 * * * *", {}, {});
}

@Span()
@CrawlerSupervisorJob.Handle()
async superviseImportJobs(): Promise<void> {
this.logger.log("Starting crawler jobs");
const users = await this.spotifyService.getCrawlableUserInfo();
const userInfo = await this.spotifyService.getCrawlableUserInfo();

// To save on Spotify API requests we have two different classes of polling intervals:
// - all users are polled at least every 10 minutes, this is a safe interval
// and no listens will be ever missed
// - if a user listened to a song within the last 60 minutes, we poll every
// minute to ensure that the UI shows new listens immediately
const POLL_RATE_INACTIVE_SEC = 10 * 60;
const POLL_RATE_ACTIVE_SEC = 1 * 60;

const INACTIVE_CUTOFF_MSEC = 60 * 60 * 1000;

await Promise.all(
users.map((user) =>
this.importSpotifyJobService.sendOnce({ userID: user.id }, {}, user.id)
)
userInfo.map(({ user, lastListen }) => {
let pollRate = POLL_RATE_INACTIVE_SEC;

const timeSinceLastListen = new Date().getTime() - lastListen.getTime();
if (timeSinceLastListen < INACTIVE_CUTOFF_MSEC) {
pollRate = POLL_RATE_ACTIVE_SEC;
}

this.importSpotifyJobService.sendThrottled(
{ userID: user.id },
{},
pollRate,
user.id
);
})
);
}

Expand Down
23 changes: 21 additions & 2 deletions src/sources/spotify/spotify.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,27 @@ export class SpotifyService {
) {}

@Span()
async getCrawlableUserInfo(): Promise<User[]> {
return this.usersService.findAll();
async getCrawlableUserInfo(): Promise<{ user: User; lastListen: Date }[]> {
// All of this is kinda inefficient, we do two db queries and join in code,
// i can't be bothered to do this properly in the db for now.
// Should be refactored if listory gets hundreds of users (lol).

const [users, listens] = await Promise.all([
this.usersService.findAll(),
this.listensService.getMostRecentListenPerUser(),
]);

return users.map((user) => {
const lastListen = listens.find((listen) => listen.user.id === user.id);

return {
user,
// Return 1970 if no listen exists
lastListen: lastListen ? lastListen.playedAt : new Date(0),
};
});

return;
}

@ImportSpotifyJob.Handle()
Expand Down

0 comments on commit 14478a5

Please sign in to comment.