Setup scheduler

This commit is contained in:
2026-01-14 01:18:39 +01:00
parent 9147479b00
commit 5f8691e1af
9 changed files with 170 additions and 0 deletions

View File

@@ -3,8 +3,10 @@ package dev.kruhlmann.imgfloat;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableAsync @EnableAsync
@EnableScheduling
@SpringBootApplication @SpringBootApplication
public class ImgfloatApplication { public class ImgfloatApplication {

View File

@@ -0,0 +1,19 @@
package dev.kruhlmann.imgfloat.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler emoteSyncTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
scheduler.setThreadNamePrefix("emote-sync-");
scheduler.initialize();
return scheduler;
}
}

View File

@@ -40,6 +40,9 @@ public class Settings {
@Column(nullable = false) @Column(nullable = false)
private int canvasFramesPerSecond; private int canvasFramesPerSecond;
@Column(nullable = false)
private int emoteSyncIntervalMinutes;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
@@ -58,6 +61,7 @@ public class Settings {
s.setMaxAssetVolumeFraction(5.0); s.setMaxAssetVolumeFraction(5.0);
s.setMaxCanvasSideLengthPixels(7680); s.setMaxCanvasSideLengthPixels(7680);
s.setCanvasFramesPerSecond(60); s.setCanvasFramesPerSecond(60);
s.setEmoteSyncIntervalMinutes(60);
return s; return s;
} }
@@ -133,6 +137,14 @@ public class Settings {
this.canvasFramesPerSecond = canvasFramesPerSecond; this.canvasFramesPerSecond = canvasFramesPerSecond;
} }
public int getEmoteSyncIntervalMinutes() {
return emoteSyncIntervalMinutes;
}
public void setEmoteSyncIntervalMinutes(int emoteSyncIntervalMinutes) {
this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes;
}
@PrePersist @PrePersist
public void initializeTimestamps() { public void initializeTimestamps() {
Instant now = Instant.now(); Instant now = Instant.now();

View File

@@ -0,0 +1,81 @@
package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
@Service
public class EmoteSyncScheduler implements SchedulingConfigurer {
private static final Logger LOG = LoggerFactory.getLogger(EmoteSyncScheduler.class);
private static final int DEFAULT_INTERVAL_MINUTES = 60;
private final SettingsService settingsService;
private final ChannelRepository channelRepository;
private final TwitchEmoteService twitchEmoteService;
private final SevenTvEmoteService sevenTvEmoteService;
private final TaskScheduler taskScheduler;
public EmoteSyncScheduler(
SettingsService settingsService,
ChannelRepository channelRepository,
TwitchEmoteService twitchEmoteService,
SevenTvEmoteService sevenTvEmoteService,
@Qualifier("emoteSyncTaskScheduler") TaskScheduler taskScheduler
) {
this.settingsService = settingsService;
this.channelRepository = channelRepository;
this.twitchEmoteService = twitchEmoteService;
this.sevenTvEmoteService = sevenTvEmoteService;
this.taskScheduler = taskScheduler;
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskScheduler);
taskRegistrar.addTriggerTask(this::syncEmotes, buildTrigger());
}
private Trigger buildTrigger() {
return (TriggerContext triggerContext) -> {
Instant lastCompletion = triggerContext.lastCompletionTime() == null
? Instant.now()
: triggerContext.lastCompletionTime().toInstant();
return Date.from(lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes())));
};
}
private int resolveIntervalMinutes() {
Settings settings = settingsService.get();
int interval = settings.getEmoteSyncIntervalMinutes();
return interval > 0 ? interval : DEFAULT_INTERVAL_MINUTES;
}
private void syncEmotes() {
int interval = resolveIntervalMinutes();
LOG.info("Synchronizing emotes (interval {} minutes)", interval);
twitchEmoteService.refreshGlobalEmotes();
List<Channel> channels = channelRepository.findAll();
for (Channel channel : channels) {
String broadcaster = channel.getBroadcaster();
twitchEmoteService.refreshChannelEmotes(broadcaster);
sevenTvEmoteService.refreshChannelEmotes(broadcaster);
}
LOG.info("Completed emote sync for {} channels", channels.size());
}
}

View File

@@ -70,6 +70,14 @@ public class SevenTvEmoteService {
return emotes.stream().map(CachedEmote::descriptor).toList(); return emotes.stream().map(CachedEmote::descriptor).toList();
} }
public void refreshChannelEmotes(String channelLogin) {
if (channelLogin == null || channelLogin.isBlank()) {
return;
}
String normalized = channelLogin.toLowerCase(Locale.ROOT);
channelEmoteCache.put(normalized, fetchChannelEmotes(normalized));
}
public Optional<EmoteAsset> loadEmoteAsset(String emoteId) { public Optional<EmoteAsset> loadEmoteAsset(String emoteId) {
if (emoteId == null || emoteId.isBlank()) { if (emoteId == null || emoteId.isBlank()) {
return Optional.empty(); return Optional.empty();

View File

@@ -71,6 +71,10 @@ public class TwitchEmoteService {
return globalEmotes.stream().map(CachedEmote::descriptor).toList(); return globalEmotes.stream().map(CachedEmote::descriptor).toList();
} }
public void refreshGlobalEmotes() {
warmGlobalEmotes();
}
public List<EmoteDescriptor> getChannelEmotes(String channelLogin) { public List<EmoteDescriptor> getChannelEmotes(String channelLogin) {
if (channelLogin == null || channelLogin.isBlank()) { if (channelLogin == null || channelLogin.isBlank()) {
return List.of(); return List.of();
@@ -80,6 +84,14 @@ public class TwitchEmoteService {
return emotes.stream().map(CachedEmote::descriptor).toList(); return emotes.stream().map(CachedEmote::descriptor).toList();
} }
public void refreshChannelEmotes(String channelLogin) {
if (channelLogin == null || channelLogin.isBlank()) {
return;
}
String normalized = channelLogin.toLowerCase(Locale.ROOT);
channelEmoteCache.put(normalized, fetchChannelEmotes(normalized));
}
public Optional<EmoteAsset> loadEmoteAsset(String emoteId) { public Optional<EmoteAsset> loadEmoteAsset(String emoteId) {
if (emoteId == null || emoteId.isBlank()) { if (emoteId == null || emoteId.isBlank()) {
return Optional.empty(); return Optional.empty();

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN emote_sync_interval_minutes INTEGER NOT NULL DEFAULT 60;

View File

@@ -8,12 +8,14 @@ const minPitchElement = document.getElementById("min-audio-pitch");
const maxPitchElement = document.getElementById("max-audio-pitch"); const maxPitchElement = document.getElementById("max-audio-pitch");
const minVolumeElement = document.getElementById("min-volume"); const minVolumeElement = document.getElementById("min-volume");
const maxVolumeElement = document.getElementById("max-volume"); const maxVolumeElement = document.getElementById("max-volume");
const emoteSyncIntervalElement = document.getElementById("emote-sync-interval");
const statusElement = document.getElementById("settings-status"); const statusElement = document.getElementById("settings-status");
const statCanvasFpsElement = document.getElementById("stat-canvas-fps"); const statCanvasFpsElement = document.getElementById("stat-canvas-fps");
const statCanvasSizeElement = document.getElementById("stat-canvas-size"); const statCanvasSizeElement = document.getElementById("stat-canvas-size");
const statPlaybackRangeElement = document.getElementById("stat-playback-range"); const statPlaybackRangeElement = document.getElementById("stat-playback-range");
const statAudioRangeElement = document.getElementById("stat-audio-range"); const statAudioRangeElement = document.getElementById("stat-audio-range");
const statVolumeRangeElement = document.getElementById("stat-volume-range"); const statVolumeRangeElement = document.getElementById("stat-volume-range");
const statEmoteSyncElement = document.getElementById("stat-emote-sync");
const sysadminListElement = document.getElementById("sysadmin-list"); const sysadminListElement = document.getElementById("sysadmin-list");
const sysadminInputElement = document.getElementById("new-sysadmin"); const sysadminInputElement = document.getElementById("new-sysadmin");
const addSysadminButtonElement = document.getElementById("add-sysadmin-button"); const addSysadminButtonElement = document.getElementById("add-sysadmin-button");
@@ -55,6 +57,7 @@ function setFormSettings(s) {
maxPitchElement.value = s.maxAssetAudioPitchFraction; maxPitchElement.value = s.maxAssetAudioPitchFraction;
minVolumeElement.value = s.minAssetVolumeFraction; minVolumeElement.value = s.minAssetVolumeFraction;
maxVolumeElement.value = s.maxAssetVolumeFraction; maxVolumeElement.value = s.maxAssetVolumeFraction;
emoteSyncIntervalElement.value = s.emoteSyncIntervalMinutes;
} }
function updateStatCards(settings) { function updateStatCards(settings) {
@@ -64,6 +67,7 @@ function updateStatCards(settings) {
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`; statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`; statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`;
statEmoteSyncElement.textContent = `${settings.emoteSyncIntervalMinutes ?? "--"} min`;
} }
function readInt(input) { function readInt(input) {
@@ -83,6 +87,7 @@ function loadUserSettingsFromDom() {
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
userSettings.emoteSyncIntervalMinutes = readInt(emoteSyncIntervalElement);
} }
function updateSubmitButtonDisabledState() { function updateSubmitButtonDisabledState() {

View File

@@ -61,6 +61,11 @@
<p class="stat-value" id="stat-volume-range">--</p> <p class="stat-value" id="stat-volume-range">--</p>
<p class="stat-subtitle">Keeps alerts comfortable</p> <p class="stat-subtitle">Keeps alerts comfortable</p>
</div> </div>
<div class="stat">
<p class="stat-label">Emote sync</p>
<p class="stat-value" id="stat-emote-sync">--</p>
<p class="stat-subtitle">Minutes between refreshes</p>
</div>
</div> </div>
</section> </section>
@@ -224,6 +229,31 @@
</p> </p>
</div> </div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Emotes</p>
<h3>Emote sync interval</h3>
<p class="muted tiny">
Choose how often Imgfloat refreshes Twitch and 7TV emote catalogs.
</p>
</div>
<div class="control-grid">
<label for="emote-sync-interval"
>Emote sync interval (minutes)
<input
id="emote-sync-interval"
name="emote-sync-interval"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\\d*$"
placeholder="60"
/>
</label>
</div>
<p class="field-hint">Set to 60 for hourly refreshes.</p>
</div>
<div class="form-footer"> <div class="form-footer">
<p id="settings-status" class="status-chip">No changes yet.</p> <p id="settings-status" class="status-chip">No changes yet.</p>
<button id="settings-submit-button" type="submit" class="button" disabled> <button id="settings-submit-button" type="submit" class="button" disabled>