diff --git a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java index 97b9fd2..a5dae60 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java +++ b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java @@ -3,8 +3,10 @@ package dev.kruhlmann.imgfloat; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync +@EnableScheduling @SpringBootApplication public class ImgfloatApplication { diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchedulingConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchedulingConfig.java new file mode 100644 index 0000000..358ba4b --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchedulingConfig.java @@ -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; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java index b50bd58..e99bb9f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java @@ -40,6 +40,9 @@ public class Settings { @Column(nullable = false) private int canvasFramesPerSecond; + @Column(nullable = false) + private int emoteSyncIntervalMinutes; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; @@ -58,6 +61,7 @@ public class Settings { s.setMaxAssetVolumeFraction(5.0); s.setMaxCanvasSideLengthPixels(7680); s.setCanvasFramesPerSecond(60); + s.setEmoteSyncIntervalMinutes(60); return s; } @@ -133,6 +137,14 @@ public class Settings { this.canvasFramesPerSecond = canvasFramesPerSecond; } + public int getEmoteSyncIntervalMinutes() { + return emoteSyncIntervalMinutes; + } + + public void setEmoteSyncIntervalMinutes(int emoteSyncIntervalMinutes) { + this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes; + } + @PrePersist public void initializeTimestamps() { Instant now = Instant.now(); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/EmoteSyncScheduler.java b/src/main/java/dev/kruhlmann/imgfloat/service/EmoteSyncScheduler.java new file mode 100644 index 0000000..dfc0316 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/EmoteSyncScheduler.java @@ -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 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()); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java index 8008280..5c8e6c8 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SevenTvEmoteService.java @@ -70,6 +70,14 @@ public class SevenTvEmoteService { 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 loadEmoteAsset(String emoteId) { if (emoteId == null || emoteId.isBlank()) { return Optional.empty(); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/TwitchEmoteService.java b/src/main/java/dev/kruhlmann/imgfloat/service/TwitchEmoteService.java index 2c9c44c..852ec95 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/TwitchEmoteService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/TwitchEmoteService.java @@ -71,6 +71,10 @@ public class TwitchEmoteService { return globalEmotes.stream().map(CachedEmote::descriptor).toList(); } + public void refreshGlobalEmotes() { + warmGlobalEmotes(); + } + public List getChannelEmotes(String channelLogin) { if (channelLogin == null || channelLogin.isBlank()) { return List.of(); @@ -80,6 +84,14 @@ public class TwitchEmoteService { 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 loadEmoteAsset(String emoteId) { if (emoteId == null || emoteId.isBlank()) { return Optional.empty(); diff --git a/src/main/resources/db/migration/V5__settings_emote_sync_interval.sql b/src/main/resources/db/migration/V5__settings_emote_sync_interval.sql new file mode 100644 index 0000000..d36b284 --- /dev/null +++ b/src/main/resources/db/migration/V5__settings_emote_sync_interval.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN emote_sync_interval_minutes INTEGER NOT NULL DEFAULT 60; diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index 995b560..77e91a9 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -8,12 +8,14 @@ const minPitchElement = document.getElementById("min-audio-pitch"); const maxPitchElement = document.getElementById("max-audio-pitch"); const minVolumeElement = document.getElementById("min-volume"); const maxVolumeElement = document.getElementById("max-volume"); +const emoteSyncIntervalElement = document.getElementById("emote-sync-interval"); const statusElement = document.getElementById("settings-status"); const statCanvasFpsElement = document.getElementById("stat-canvas-fps"); const statCanvasSizeElement = document.getElementById("stat-canvas-size"); const statPlaybackRangeElement = document.getElementById("stat-playback-range"); const statAudioRangeElement = document.getElementById("stat-audio-range"); const statVolumeRangeElement = document.getElementById("stat-volume-range"); +const statEmoteSyncElement = document.getElementById("stat-emote-sync"); const sysadminListElement = document.getElementById("sysadmin-list"); const sysadminInputElement = document.getElementById("new-sysadmin"); const addSysadminButtonElement = document.getElementById("add-sysadmin-button"); @@ -55,6 +57,7 @@ function setFormSettings(s) { maxPitchElement.value = s.maxAssetAudioPitchFraction; minVolumeElement.value = s.minAssetVolumeFraction; maxVolumeElement.value = s.maxAssetVolumeFraction; + emoteSyncIntervalElement.value = s.emoteSyncIntervalMinutes; } function updateStatCards(settings) { @@ -64,6 +67,7 @@ function updateStatCards(settings) { statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; + statEmoteSyncElement.textContent = `${settings.emoteSyncIntervalMinutes ?? "--"} min`; } function readInt(input) { @@ -83,6 +87,7 @@ function loadUserSettingsFromDom() { userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); + userSettings.emoteSyncIntervalMinutes = readInt(emoteSyncIntervalElement); } function updateSubmitButtonDisabledState() { diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 8279c70..a733180 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -61,6 +61,11 @@

--

Keeps alerts comfortable

+
+

Emote sync

+

--

+

Minutes between refreshes

+
@@ -224,6 +229,31 @@

+
+
+

Emotes

+

Emote sync interval

+

+ Choose how often Imgfloat refreshes Twitch and 7TV emote catalogs. +

+
+
+ +
+

Set to 60 for hourly refreshes.

+
+