mirror of
https://github.com/imgfloat/server.git
synced 2026-03-23 15:10:40 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fb1921da | |||
| ed5007538b | |||
| 0f088dc83b | |||
| fe6fb68b53 | |||
| 1c6d115181 | |||
| 1c118aab0c | |||
| d6271b1758 | |||
| f9613c7c2f | |||
| 1d48b7d5e7 | |||
| 39bb599219 |
102
AGENTS.md
Normal file
102
AGENTS.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Snapshot & Stack
|
||||||
|
- Java Spring Boot 3.2 service that backs the Imgfloat livestream overlays, admin UI, and dashboard.
|
||||||
|
- Uses Spring MVC controllers, WebSocket/STOMP broadcasts (`SimpMessagingTemplate`), OAuth2/Twitch authentication, and scheduled/async services (`@EnableScheduling`, `@EnableAsync` in `ImgfloatApplication`).
|
||||||
|
- Media-heavy assets are stored on disk (`assets/`, `previews/`), processed with `ffmpeg`/`ffprobe`, and surfaced through `static/js` / Thymeleaf templates under `src/main/resources`.
|
||||||
|
|
||||||
|
## Architecture & Modules
|
||||||
|
- `src/main/java/dev/kruhlmann/imgfloat/config`: Spring configuration beans (upload limits, security overrides, asset storage wiring).
|
||||||
|
- `controller`: REST + view controllers keep endpoints thin and delegate work to services and repositories.
|
||||||
|
- `service`: business logic (asset management, marketplace seeds, git info, Twitch emote syncing, audit logging, cleanup, media optimization, etc.). `service.media` contains ffmpeg helpers and media detection.
|
||||||
|
- `repository`: Spring Data JPA interfaces for the Imgfloat DB (`Asset`, `Channel`, `ScriptAsset`, etc.) and a separate audit DB under `repository.audit`.
|
||||||
|
- `model`: `db` packages mirror schema entities; `api.request`/`response` hold DTOs used by controllers.
|
||||||
|
- `templates`: Thymeleaf views (`admin.html`, `broadcast.html`, `dashboard.html`, etc.).
|
||||||
|
- `static`: frontend JavaScript modules (`static/js/admin`, `static/js/broadcast/*`, `static/js/customAssets.js`, etc.), CSS, icons, and broadcast worker scripts. Keep new assets next to the feature they support.
|
||||||
|
- `src/main/resources/db/migration` and `db/audit`: Flyway migration scripts that must stay in sync with the SQLite schemas referenced by `IMGFLOAT_DB_PATH` and `IMGFLOAT_AUDIT_DB_PATH`.
|
||||||
|
|
||||||
|
## Environment & Configuration
|
||||||
|
- `.env` files are only loaded when you run `make run`; shelling `mvn spring-boot:run` directly requires manually exporting each variable.
|
||||||
|
- Required environment variables (set them via `.env` locally or through your deployment tooling):
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `IMGFLOAT_ASSETS_PATH` | Base filesystem directory for uploaded assets. `AssetStorageService` sanitizes broadcaster segments under this path. |
|
||||||
|
| `IMGFLOAT_PREVIEWS_PATH` | Where generated previews (PNG thumbnails) go. |
|
||||||
|
| `IMGFLOAT_DB_PATH` | SQLite file used by the main Flyway-managed schema (`db/migration`). |
|
||||||
|
| `IMGFLOAT_AUDIT_DB_PATH` | SQLite file for audit logs (`db/audit`) and session storage. |
|
||||||
|
| `IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN` | Twitch login that becomes the initial sysadmin and can manage all channels. |
|
||||||
|
| `IMGFLOAT_GITHUB_CLIENT_OWNER/REPO/VERSION` | GitHub release coordinates used by `GithubReleaseService` to build download links for the client bundle. |
|
||||||
|
| `IMGFLOAT_TOKEN_ENCRYPTION_KEY` | Base64/Base64URL-encoded 32-byte key used by Spring Security to encrypt OAuth tokens at rest. |
|
||||||
|
| `SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE` / `MAX_REQUEST_SIZE` | Control upload limits (defaults to 10MB in `Makefile`, also read by `UploadLimitsConfig` which exposes `uploadLimitBytes` to controllers/services). |
|
||||||
|
| `TWITCH_CLIENT_ID` & `TWITCH_CLIENT_SECRET` | Used by Spring Security’s OAuth2 client registration in `application.yml`. |
|
||||||
|
|
||||||
|
- Optional/operational variables:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `IMGFLOAT_COMMIT_URL_PREFIX` | Enables the commit chip in the UI (`GitInfoService`) when paired with `git-commit-id-plugin`. |
|
||||||
|
| `IMGFLOAT_DOCS_URL` | Base URL for documentation links rendered in templates. |
|
||||||
|
| `IMGFLOAT_IS_STAGING` | Set to `1` to surface a staging banner across non-broadcast pages. Defaults to `0`. |
|
||||||
|
| `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Directory containing marketplace seed directories (`MarketplaceScriptSeedLoader` reads metadata, source.js, logo, attachments). `doc/marketplace-scripts` holds examples. |
|
||||||
|
| `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Toggle to let sysadmins manage every channel without being added as an admin. |
|
||||||
|
| `TWITCH_REDIRECT_URI` | Overrides the default redirect URI for OAuth (`{baseUrl}/login/oauth2/code/twitch`). |
|
||||||
|
| `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to decrypt tokens created before rotating `IMGFLOAT_TOKEN_ENCRYPTION_KEY`. |
|
||||||
|
|
||||||
|
- `AssetStorageService` will fall back to system temp dirs (`java.io.tmpdir/imgfloat-assets` & `-previews`) if `IMGFLOAT_ASSETS_PATH`/`PREVIEWS_PATH` are unset, but storing data on persistent storage is required for production.
|
||||||
|
- `UploadLimitsConfig` defaults to 50MB/100MB for tests if the environment properties are absent; production should honor the `spring.servlet.multipart.*` values you expose.
|
||||||
|
|
||||||
|
## Build, Run, & Watch
|
||||||
|
- `make build` / `mvn compile`: compile the server.
|
||||||
|
- `make run`: sources `.env` (if present) and runs `mvn spring-boot:run`. Use this for local dev; hot reload works via `spring-devtools`.
|
||||||
|
- `make watch`: compiles continuously using `entr` (sleep loop watches `src/main`). Requires `entr` installed and you must refresh the browser manually after the compile succeeds.
|
||||||
|
- `make test` / `mvn test`: run the JUnit 5 + Mockito suite (including integration tests that spin up the Spring context). Run this before pushing changes that touch controllers, services, or persistence logic.
|
||||||
|
- `make package` / `mvn clean package`: clean build that packages the runnable JAR.
|
||||||
|
- `springdoc-openapi` is on the classpath; the automated Swagger UI can be helpful for understanding the available REST endpoints.
|
||||||
|
|
||||||
|
## Database & Persistence
|
||||||
|
- The main SQLite DB (`IMGFLOAT_DB_PATH`) holds channel, user, asset, marketplace, and settings tables. Flyway (configured in `application.yml`) runs migrations from `classpath:db/migration`. Don’t bypass these migration scripts when updating schemas.
|
||||||
|
- Audit logs live in the separate `IMGFLOAT_AUDIT_DB_PATH` with migrations under `src/main/resources/db/audit`. The audit DB also stores Spring Session tables; `spring.session.jdbc.initialize-schema` is set to `never`, so the Flyway scripts **must** include the session table definitions.
|
||||||
|
- `AuditLogService` sanitizes log entries before persisting them and is used by controllers such as `AuditLogApiController` and `AccountService`.
|
||||||
|
|
||||||
|
## Media & Asset Handling
|
||||||
|
- `ChannelDirectoryService` handles uploads. It enforces `uploadLimitBytes` (derived from `spring.servlet.multipart.max-file-size`), normalizes broadcaster names, and records audit events via `AuditLogService`.
|
||||||
|
- Uploaded assets go through `MediaDetectionService`, `MediaOptimizationService`, and `AssetStorageService` which writes to `IMGFLOAT_ASSETS_PATH`. Previews (PNGs) are written to `IMGFLOAT_PREVIEWS_PATH`.
|
||||||
|
- `AssetStorageService` sanitizes broadcaster segments (`sanitizeUserSegment`) to avoid traversal and auto-creates directories. When deleting, `AssetCleanupService` runs asynchronously after startup to remove orphan files that are not referenced by any DB record.
|
||||||
|
- Media processing uses `ffmpeg`/`ffprobe` via `FfmpegService`. Make sure those binaries are available on the system; otherwise video/preview transcodes and APNG → GIF conversions will fail with warnings logged from `FfmpegService`.
|
||||||
|
|
||||||
|
## Marketplace Scripts & Allowed Domains
|
||||||
|
- Marketplace seeds live under the path configured by `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH`. Each subfolder is a listing identifier and **must** contain `metadata.json` and `source.js`. Optional files: `logo.png` (renders as the marketplace badge) and `attachments/` for extra files (filenames must be unique per script).
|
||||||
|
- `metadata.json` combines `name`, optional `description`, optional `broadcaster`, and optional `allowedDomains`. Allowed domains control the `fetch` sandbox in the broadcast worker and are normalized via `ChannelDirectoryService.normalizeAllowedDomains`. Valid entries are hostname or hostname+port (regex `^[a-z0-9.-]+(?::[0-9]{1,5})?$`), de-duplicated, lower-cased, and capped at 32 entries per script.
|
||||||
|
- Seeds are cached at startup; `MarketplaceScriptSeedLoader` exposes `listEntriesForQuery` and `findById` so the frontend can show the marketplace catalog without hitting Twitch/SevenTV endpoints.
|
||||||
|
- Example scripts are shipped under `doc/marketplace-scripts/`.
|
||||||
|
|
||||||
|
## Security, Auth, & Tokens
|
||||||
|
- Spring Security OAuth2 login is configured in `application.yml` under `spring.security.oauth2.client`. Twitch is the only configured provider; scopes include `user:read:email` and `moderation:read`.
|
||||||
|
- OAuth tokens are encrypted using `IMGFLOAT_TOKEN_ENCRYPTION_KEY`; rotate keys by setting `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` to a comma list of old keys (oldest last) before swapping in a new `IMGFLOAT_TOKEN_ENCRYPTION_KEY`.
|
||||||
|
- `IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN` seeds the first sysadmin; subsequent sysadmins can be managed via the UI or API. To allow sysadmins to hop between channels without being channel admins, set `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED=true`.
|
||||||
|
- `GitInfoService` will expose a commit link if `IMGFLOAT_COMMIT_URL_PREFIX` is configured and `git.properties` is present (generated by the `git-commit-id-plugin`), otherwise it falls back to running `git` directly.
|
||||||
|
- `GithubReleaseService` fails fast when `IMGFLOAT_GITHUB_CLIENT_OWNER`, `IMGFLOAT_GITHUB_CLIENT_REPO`, or `IMGFLOAT_GITHUB_CLIENT_VERSION` are missing; these powers the download link in the UI.
|
||||||
|
|
||||||
|
## Frontend Notes
|
||||||
|
- Broadcast client code lives in `src/main/resources/static/js/broadcast/` (renderer, workers, runtime helpers). When editing overlay scripts, keep worker changes in `script-worker.js` and keep main page logic in `renderer.js`.
|
||||||
|
- Admin/dashboard JS modules (`customAssets.js`, `settings.js`, `channels.js`, etc.) are plain ES modules bundled through Thymeleaf templates, so keep related CSS/HTML under `static/css` and `templates`.
|
||||||
|
- Templates render dynamic data via controllers such as `ViewController`, which also injects `uploadLimitBytes`, version info (`VersionService`), and feature flags (staging banner, docs URL, commit chip wrapped in `GitInfoService`/`GithubReleaseService` values).
|
||||||
|
|
||||||
|
## Testing & Validation
|
||||||
|
- Tests are under `src/test/java/dev/kruhlmann/imgfloat`. Controller integration tests (e.g., `ChannelApiIntegrationTest`) boot up the application context—mock external services carefully or rely on the in-memory SQLite test DB created from the Flyway scripts.
|
||||||
|
- Service unit tests (e.g., `ChannelDirectoryServiceTest`) cover asset normalization, allowed domain semantics, and marketplace seed behavior. Add targeted tests for new service logic, API validations, or asset handling (uploads, attachments, downloads).
|
||||||
|
- Run `make test` before shipping changes; it also generates JaCoCo reports via the Maven plugin.
|
||||||
|
|
||||||
|
## Coding Style & Contributions
|
||||||
|
- Java code uses 4-space indentation, `UpperCamelCase` for types, `lowerCamelCase` for fields/methods, and `UPPER_SNAKE_CASE` for constants. DTOs often use `record`s when immutability is feasible (`ScriptMarketplaceEntry`, `MarketplaceScriptSeedLoader.SeedScript`, etc.).
|
||||||
|
- Prefer constructor injection for Spring components. Controllers should remain thin and delegate to service-layer beans; services orchestrate repositories, asset storage, storage cleanup, Git/Twitch helpers, and messaging (websockets/events).
|
||||||
|
- Keep assets close to their feature (e.g., `static/js/broadcast/` for overlay code, `templates/` for the Thymeleaf view referencing that script).
|
||||||
|
- Avoid path traversal by relying on existing sanitizers (`AssetStorageService.sanitizeUserSegment`). When adding new file paths, reuse `safeJoin`/`sanitizeUserSegment` patterns.
|
||||||
|
- When syncing marketplace favorites or script attachments, obey `ChannelDirectoryService.loadScriptAttachments` semantics—attachments are stored in `script_asset_attachment` records and are cleaned up when orphaned.
|
||||||
|
|
||||||
|
## Operational Tips
|
||||||
|
- `AssetCleanupService` runs asynchronously once per startup (after `ApplicationReadyEvent`) and purges disk files whose IDs are not referenced by any DB row; do not rely on manual cleanup unless debugging.
|
||||||
|
- `VersionService` looks for `META-INF/maven/dev.kruhlmann/imgfloat/pom.properties` first, falls back to parsing `pom.xml` (useful when running from source), and throws if no version is available—keep your `pom.version` accurate for release builds.
|
||||||
|
- `EmoteSyncScheduler`, `SevenTvEmoteService`, `TwitchEmoteService`, and `TwitchAppAccessTokenService` orchestrate Twitch/7TV emote catalogs and should be aware of scheduling constraints if you modify emote ingestion.
|
||||||
|
- Static files, marketplace metadata, and assets are best tested locally by running `make run` with the appropriate env variables and by refreshing the admin/broadcast UI manually.
|
||||||
8
pom.xml
8
pom.xml
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>dev.kruhlmann</groupId>
|
<groupId>dev.kruhlmann</groupId>
|
||||||
<artifactId>imgfloat</artifactId>
|
<artifactId>imgfloat</artifactId>
|
||||||
<version>0.0.1</version>
|
<version>0.0.3</version>
|
||||||
<name>Imgfloat</name>
|
<name>Imgfloat</name>
|
||||||
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
|
<description>Livestream overlay with Twitch-authenticated channel admins and broadcasters.</description>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
@@ -131,6 +131,12 @@
|
|||||||
<artifactId>spring-security-test</artifactId>
|
<artifactId>spring-security-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains</groupId>
|
||||||
|
<artifactId>annotations</artifactId>
|
||||||
|
<version>13.0</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ApplicationLifecycleLogger {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ApplicationLifecycleLogger.class);
|
||||||
|
|
||||||
|
private final String serverPort;
|
||||||
|
|
||||||
|
public ApplicationLifecycleLogger(@Value("${server.port:8080}") String serverPort) {
|
||||||
|
this.serverPort = serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void logReady() {
|
||||||
|
LOG.info("Imgfloat ready to accept connections on port {}", serverPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,18 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
|
|||||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
|
import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
|
||||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings;
|
import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings;
|
||||||
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
|
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
|
||||||
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableTransactionManagement
|
||||||
@EnableJpaRepositories(
|
@EnableJpaRepositories(
|
||||||
basePackages = "dev.kruhlmann.imgfloat.repository.audit",
|
basePackages = "dev.kruhlmann.imgfloat.repository.audit",
|
||||||
entityManagerFactoryRef = "auditEntityManagerFactory",
|
entityManagerFactoryRef = "auditEntityManagerFactory",
|
||||||
@@ -36,9 +38,7 @@ public class AuditLogDataSourceConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConfigurationProperties("imgfloat.audit.datasource.hikari")
|
@ConfigurationProperties("imgfloat.audit.datasource.hikari")
|
||||||
public HikariDataSource auditDataSource(
|
public HikariDataSource auditDataSource(@Qualifier("auditDataSourceProperties") DataSourceProperties properties) {
|
||||||
@Qualifier("auditDataSourceProperties") DataSourceProperties properties
|
|
||||||
) {
|
|
||||||
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ public class AuditLogDataSourceConfig {
|
|||||||
) {
|
) {
|
||||||
return builder
|
return builder
|
||||||
.dataSource(dataSource)
|
.dataSource(dataSource)
|
||||||
.packages("dev.kruhlmann.imgfloat.audit.model")
|
.packages("dev.kruhlmann.imgfloat.model.db.audit")
|
||||||
.properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings()))
|
.properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings()))
|
||||||
.persistenceUnit("audit")
|
.persistenceUnit("audit")
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ import org.springframework.transaction.PlatformTransactionManager;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@EnableJpaRepositories(
|
@EnableJpaRepositories(
|
||||||
basePackages = "dev.kruhlmann.imgfloat.repository",
|
basePackages = "dev.kruhlmann.imgfloat.repository",
|
||||||
excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AuditLogRepository.class),
|
excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AuditLogRepository.class)
|
||||||
entityManagerFactoryRef = "entityManagerFactory",
|
|
||||||
transactionManagerRef = "transactionManager"
|
|
||||||
)
|
)
|
||||||
public class PrimaryDataSourceConfig {
|
public class PrimaryDataSourceConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
|||||||
private static final String TABLE_NAME = "oauth2_authorized_client";
|
private static final String TABLE_NAME = "oauth2_authorized_client";
|
||||||
|
|
||||||
private final JdbcOperations jdbcOperations;
|
private final JdbcOperations jdbcOperations;
|
||||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
|
||||||
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
||||||
private final OAuthTokenCipher tokenCipher;
|
private final OAuthTokenCipher tokenCipher;
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
|||||||
OAuthTokenCipher tokenCipher
|
OAuthTokenCipher tokenCipher
|
||||||
) {
|
) {
|
||||||
this.jdbcOperations = jdbcOperations;
|
this.jdbcOperations = jdbcOperations;
|
||||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
|
||||||
this.tokenCipher = tokenCipher;
|
this.tokenCipher = tokenCipher;
|
||||||
this.rowMapper = (rs, rowNum) -> {
|
this.rowMapper = (rs, rowNum) -> {
|
||||||
String registrationId = rs.getString("client_registration_id");
|
String registrationId = rs.getString("client_registration_id");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain;
|
|||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -154,11 +155,12 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
OncePerRequestFilter csrfTokenCookieFilter() {
|
OncePerRequestFilter csrfTokenCookieFilter() {
|
||||||
return new OncePerRequestFilter() {
|
return new OncePerRequestFilter() {
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
HttpServletRequest request,
|
@NotNull HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
@NotNull HttpServletResponse response,
|
||||||
FilterChain filterChain
|
@NotNull FilterChain filterChain
|
||||||
) throws java.io.IOException, jakarta.servlet.ServletException {
|
) throws java.io.IOException, jakarta.servlet.ServletException {
|
||||||
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
|
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
|
||||||
if (csrfToken == null) {
|
if (csrfToken == null) {
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ public class SystemEnvironmentValidator {
|
|||||||
@Value("${IMGFLOAT_GITHUB_CLIENT_VERSION:#{null}}")
|
@Value("${IMGFLOAT_GITHUB_CLIENT_VERSION:#{null}}")
|
||||||
private String githubClientVersion;
|
private String githubClientVersion;
|
||||||
|
|
||||||
private long maxUploadBytes;
|
|
||||||
private long maxRequestBytes;
|
|
||||||
|
|
||||||
public SystemEnvironmentValidator(Environment environment) {
|
public SystemEnvironmentValidator(Environment environment) {
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
}
|
}
|
||||||
@@ -72,8 +69,8 @@ public class SystemEnvironmentValidator {
|
|||||||
|
|
||||||
StringBuilder missing = new StringBuilder();
|
StringBuilder missing = new StringBuilder();
|
||||||
|
|
||||||
maxUploadBytes = DataSize.parse(springMaxFileSize).toBytes();
|
long maxUploadBytes = DataSize.parse(springMaxFileSize).toBytes();
|
||||||
maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
|
long maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
|
||||||
checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
|
checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
|
||||||
checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
|
checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
|
||||||
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
|
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
|
||||||
@@ -107,7 +104,7 @@ public class SystemEnvironmentValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void checkString(String value, String name, StringBuilder missing) {
|
private void checkString(String value, String name, StringBuilder missing) {
|
||||||
if (value != null && StringUtils.hasText(value)) {
|
if (StringUtils.hasText(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
missing.append(" - ").append(name).append("\n");
|
missing.append(" - ").append(name).append("\n");
|
||||||
@@ -121,7 +118,7 @@ public class SystemEnvironmentValidator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String redact(String value) {
|
private String redact(String value) {
|
||||||
if (value != null && StringUtils.hasText(value)) {
|
if (StringUtils.hasText(value)) {
|
||||||
return "**************";
|
return "**************";
|
||||||
}
|
}
|
||||||
return "<not set>";
|
return "<not set>";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @Nullable RequestEntity<?> convert(@Nullable OAuth2AuthorizationCodeGrantRequest request) {
|
public @Nullable RequestEntity<?> convert(@Nullable OAuth2AuthorizationCodeGrantRequest request) {
|
||||||
|
assert request != null;
|
||||||
RequestEntity<?> entity = delegate.convert(request);
|
RequestEntity<?> entity = delegate.convert(request);
|
||||||
if (entity == null || !(entity.getBody() instanceof MultiValueMap<?, ?> existingBody)) {
|
if (entity == null || !(entity.getBody() instanceof MultiValueMap<?, ?> existingBody)) {
|
||||||
return entity;
|
return entity;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package dev.kruhlmann.imgfloat.config;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.client.ClientHttpResponse;
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
@@ -58,26 +60,15 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
|
|||||||
return new OAuth2AuthorizationException(oauth2Error, ex);
|
return new OAuth2AuthorizationException(oauth2Error, ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class CachedBodyClientHttpResponse implements ClientHttpResponse {
|
private record CachedBodyClientHttpResponse(ClientHttpResponse delegate, byte[] body) implements ClientHttpResponse {
|
||||||
|
|
||||||
private final ClientHttpResponse delegate;
|
|
||||||
private final byte[] body;
|
|
||||||
|
|
||||||
private CachedBodyClientHttpResponse(ClientHttpResponse delegate, byte[] body) {
|
|
||||||
this.delegate = delegate;
|
|
||||||
this.body = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public org.springframework.http.HttpStatusCode getStatusCode() throws IOException {
|
public org.springframework.http.HttpStatusCode getStatusCode() throws IOException {
|
||||||
return delegate.getStatusCode();
|
return delegate.getStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@NotNull
|
||||||
public int getRawStatusCode() throws IOException {
|
|
||||||
return delegate.getRawStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getStatusText() throws IOException {
|
public String getStatusText() throws IOException {
|
||||||
return delegate.getStatusText();
|
return delegate.getStatusText();
|
||||||
@@ -88,11 +79,13 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
|
|||||||
delegate.close();
|
delegate.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public java.io.InputStream getBody() throws IOException {
|
public java.io.InputStream getBody() {
|
||||||
return new ByteArrayInputStream(body);
|
return new ByteArrayInputStream(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public org.springframework.http.HttpHeaders getHeaders() {
|
public org.springframework.http.HttpHeaders getHeaders() {
|
||||||
return delegate.getHeaders();
|
return delegate.getHeaders();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.controller;
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.AuditLogEntryView;
|
import dev.kruhlmann.imgfloat.model.api.response.AuditLogEntryView;
|
||||||
import dev.kruhlmann.imgfloat.model.AuditLogPageView;
|
import dev.kruhlmann.imgfloat.model.api.response.AuditLogPageView;
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.service.AuditLogService;
|
import dev.kruhlmann.imgfloat.service.AuditLogService;
|
||||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
package dev.kruhlmann.imgfloat.controller;
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
|
||||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.AdminRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.AdminRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
|
||||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView;
|
import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
|
||||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
|
||||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
||||||
@@ -32,10 +32,13 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
|
||||||
|
import org.springframework.security.oauth2.core.AbstractOAuth2Token;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -83,10 +86,10 @@ public class ChannelApiController {
|
|||||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
|
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
|
||||||
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
|
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
|
||||||
String logRequestUsername = LogSanitizer.sanitize(request.getUsername());
|
String logRequestUsername = LogSanitizer.sanitize(request.username());
|
||||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster);
|
LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster);
|
||||||
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername(), sessionUsername);
|
boolean added = channelDirectoryService.addAdmin(broadcaster, request.username(), sessionUsername);
|
||||||
if (!added) {
|
if (!added) {
|
||||||
LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster);
|
LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster);
|
||||||
}
|
}
|
||||||
@@ -106,14 +109,14 @@ public class ChannelApiController {
|
|||||||
LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername);
|
LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername);
|
||||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
|
List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
|
||||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request);
|
||||||
String accessToken = Optional.ofNullable(authorizedClient)
|
String accessToken = Optional.ofNullable(authorizedClient)
|
||||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||||
.map((token) -> token.getTokenValue())
|
.map(AbstractOAuth2Token::getTokenValue)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
String clientId = Optional.ofNullable(authorizedClient)
|
String clientId = Optional.ofNullable(authorizedClient)
|
||||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||||
.map((registration) -> registration.getClientId())
|
.map(ClientRegistration::getClientId)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
||||||
}
|
}
|
||||||
@@ -130,7 +133,7 @@ public class ChannelApiController {
|
|||||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||||
LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername);
|
LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername);
|
||||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request);
|
||||||
|
|
||||||
if (authorizedClient == null) {
|
if (authorizedClient == null) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
@@ -140,13 +143,13 @@ public class ChannelApiController {
|
|||||||
);
|
);
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
String accessToken = Optional.ofNullable(authorizedClient)
|
String accessToken = Optional.of(authorizedClient)
|
||||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||||
.map((token) -> token.getTokenValue())
|
.map(AbstractOAuth2Token::getTokenValue)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
String clientId = Optional.ofNullable(authorizedClient)
|
String clientId = Optional.of(authorizedClient)
|
||||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||||
.map((registration) -> registration.getClientId())
|
.map(ClientRegistration::getClientId)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
||||||
LOG.warn(
|
LOG.warn(
|
||||||
@@ -327,8 +330,26 @@ public class ChannelApiController {
|
|||||||
logBroadcaster,
|
logBroadcaster,
|
||||||
logSessionUsername
|
logSessionUsername
|
||||||
);
|
);
|
||||||
return createAsset404();
|
return createAsset404();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/assets/order")
|
||||||
|
public ResponseEntity<Void> reorderAssets(
|
||||||
|
@PathVariable("broadcaster") String broadcaster,
|
||||||
|
@Valid @RequestBody AssetOrderRequest request,
|
||||||
|
OAuth2AuthenticationToken oauthToken
|
||||||
|
) {
|
||||||
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
|
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
|
||||||
|
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
|
||||||
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||||
|
broadcaster,
|
||||||
|
sessionUsername
|
||||||
|
);
|
||||||
|
LOG.debug("Reordering assets for {} by {}", logBroadcaster, logSessionUsername);
|
||||||
|
channelDirectoryService.reorderAssets(broadcaster, request.getUpdates(), sessionUsername);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/assets/{assetId}/play")
|
@PostMapping("/assets/{assetId}/play")
|
||||||
@@ -406,7 +427,7 @@ public class ChannelApiController {
|
|||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes())
|
.body(content.bytes())
|
||||||
)
|
)
|
||||||
.orElseThrow(() -> createAsset404());
|
.orElseThrow(this::createAsset404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content")
|
@GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content")
|
||||||
@@ -433,7 +454,7 @@ public class ChannelApiController {
|
|||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes())
|
.body(content.bytes())
|
||||||
)
|
)
|
||||||
.orElseThrow(() -> createAsset404());
|
.orElseThrow(this::createAsset404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/assets/{assetId}/logo")
|
@GetMapping("/assets/{assetId}/logo")
|
||||||
@@ -456,7 +477,7 @@ public class ChannelApiController {
|
|||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes())
|
.body(content.bytes())
|
||||||
)
|
)
|
||||||
.orElseThrow(() -> createAsset404());
|
.orElseThrow(this::createAsset404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/assets/{assetId}/preview")
|
@GetMapping("/assets/{assetId}/preview")
|
||||||
@@ -475,13 +496,12 @@ public class ChannelApiController {
|
|||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
.body(content.bytes())
|
.body(content.bytes())
|
||||||
)
|
)
|
||||||
.orElseThrow(() -> createAsset404());
|
.orElseThrow(this::createAsset404);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String contentDispositionFor(String mediaType) {
|
private String contentDispositionFor(String mediaType) {
|
||||||
if (
|
if (
|
||||||
mediaType != null &&
|
dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
|
||||||
dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
|
|
||||||
) {
|
) {
|
||||||
return "inline";
|
return "inline";
|
||||||
}
|
}
|
||||||
@@ -616,14 +636,11 @@ public class ChannelApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private OAuth2AuthorizedClient resolveAuthorizedClient(
|
private OAuth2AuthorizedClient resolveAuthorizedClient(
|
||||||
OAuth2AuthenticationToken oauthToken,
|
@Nullable OAuth2AuthenticationToken oauthToken,
|
||||||
OAuth2AuthorizedClient authorizedClient,
|
|
||||||
HttpServletRequest request
|
HttpServletRequest request
|
||||||
) {
|
) {
|
||||||
if (authorizedClient != null) {
|
|
||||||
return authorizedClient;
|
|
||||||
}
|
|
||||||
if (oauthToken == null) {
|
if (oauthToken == null) {
|
||||||
|
LOG.error("Attempt to resolve authorized client without oauth token");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
|
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||||
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
|
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
|
||||||
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class ChannelPreviewWsController {
|
||||||
|
|
||||||
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ChannelPreviewWsController(
|
||||||
|
ChannelDirectoryService channelDirectoryService,
|
||||||
|
AuthorizationService authorizationService,
|
||||||
|
SimpMessagingTemplate messagingTemplate
|
||||||
|
) {
|
||||||
|
this.channelDirectoryService = channelDirectoryService;
|
||||||
|
this.authorizationService = authorizationService;
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@MessageMapping("/channel/{broadcaster}/assets/{assetId}/preview")
|
||||||
|
public void previewTransform(
|
||||||
|
@DestinationVariable String broadcaster,
|
||||||
|
@DestinationVariable String assetId,
|
||||||
|
@Payload @Valid TransformRequest request,
|
||||||
|
Principal principal
|
||||||
|
) {
|
||||||
|
String sessionUsername = sessionUsername(principal);
|
||||||
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||||
|
broadcaster,
|
||||||
|
sessionUsername
|
||||||
|
);
|
||||||
|
channelDirectoryService
|
||||||
|
.previewTransform(broadcaster, assetId, request)
|
||||||
|
.ifPresent((patch) -> messagingTemplate.convertAndSend(
|
||||||
|
topicFor(broadcaster),
|
||||||
|
AssetEvent.preview(broadcaster, assetId, patch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sessionUsername(Principal principal) {
|
||||||
|
if (principal instanceof OAuth2AuthenticationToken token) {
|
||||||
|
OauthSessionUser user = OauthSessionUser.from(token);
|
||||||
|
return user == null ? null : user.login();
|
||||||
|
}
|
||||||
|
return principal == null ? null : principal.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String topicFor(String broadcaster) {
|
||||||
|
return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.controller;
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.ErrorResponse;
|
import dev.kruhlmann.imgfloat.model.api.response.ErrorResponse;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package dev.kruhlmann.imgfloat.controller;
|
|||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceImportRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.ScriptMarketplaceImportRequest;
|
||||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
import dev.kruhlmann.imgfloat.util.LogSanitizer;
|
import dev.kruhlmann.imgfloat.util.LogSanitizer;
|
||||||
@@ -78,14 +78,14 @@ public class ScriptMarketplaceController {
|
|||||||
) {
|
) {
|
||||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||||
request.getTargetBroadcaster(),
|
request.targetBroadcaster(),
|
||||||
sessionUsername
|
sessionUsername
|
||||||
);
|
);
|
||||||
String logScriptId = LogSanitizer.sanitize(scriptId);
|
String logScriptId = LogSanitizer.sanitize(scriptId);
|
||||||
String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster());
|
String logTarget = LogSanitizer.sanitize(request.targetBroadcaster());
|
||||||
LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget);
|
LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget);
|
||||||
return channelDirectoryService
|
return channelDirectoryService
|
||||||
.importMarketplaceScript(request.getTargetBroadcaster(), scriptId, sessionUsername)
|
.importMarketplaceScript(request.targetBroadcaster(), scriptId, sessionUsername)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.controller;
|
package dev.kruhlmann.imgfloat.controller;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
@@ -39,7 +39,7 @@ public class SettingsApiController {
|
|||||||
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
||||||
|
|
||||||
Settings currentSettings = settingsService.get();
|
Settings currentSettings = settingsService.get();
|
||||||
LOG.info("Sytem administrator settings change request");
|
LOG.info("System administrator settings change request");
|
||||||
settingsService.logSettings("From: ", currentSettings);
|
settingsService.logSettings("From: ", currentSettings);
|
||||||
settingsService.logSettings("To: ", newSettings);
|
settingsService.logSettings("To: ", newSettings);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
import dev.kruhlmann.imgfloat.service.GitInfoService;
|
import dev.kruhlmann.imgfloat.service.GitInfoService;
|
||||||
@@ -83,14 +83,6 @@ public class ViewController {
|
|||||||
return "index";
|
return "index";
|
||||||
}
|
}
|
||||||
|
|
||||||
@org.springframework.web.bind.annotation.GetMapping("/channels")
|
|
||||||
public String channelDirectory(Model model) {
|
|
||||||
LOG.info("Rendering channel directory");
|
|
||||||
addStagingAttribute(model);
|
|
||||||
addVersionAttributes(model);
|
|
||||||
return "channels";
|
|
||||||
}
|
|
||||||
|
|
||||||
@org.springframework.web.bind.annotation.GetMapping("/terms")
|
@org.springframework.web.bind.annotation.GetMapping("/terms")
|
||||||
public String termsOfUse(Model model) {
|
public String termsOfUse(Model model) {
|
||||||
LOG.info("Rendering terms of use");
|
LOG.info("Rendering terms of use");
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public class AdminRequest {
|
|
||||||
|
|
||||||
@NotBlank
|
|
||||||
private String username;
|
|
||||||
|
|
||||||
public String getUsername() {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUsername(String username) {
|
|
||||||
this.username = username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
|
|
||||||
public class ScriptMarketplaceImportRequest {
|
|
||||||
|
|
||||||
@NotBlank
|
|
||||||
private String targetBroadcaster;
|
|
||||||
|
|
||||||
public String getTargetBroadcaster() {
|
|
||||||
return targetBroadcaster;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTargetBroadcaster(String targetBroadcaster) {
|
|
||||||
this.targetBroadcaster = targetBroadcaster;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AdminRequest(@NotBlank String username) {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class AssetOrderRequest {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty
|
||||||
|
private List<AssetOrderUpdate> updates;
|
||||||
|
|
||||||
|
public List<AssetOrderUpdate> getUpdates() {
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdates(List<AssetOrderUpdate> updates) {
|
||||||
|
this.updates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static record AssetOrderUpdate(
|
||||||
|
@NotBlank
|
||||||
|
String assetId,
|
||||||
|
@NotNull
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
|
|
||||||
public class CanvasSettingsRequest {
|
public class CanvasSettingsRequest {
|
||||||
|
|
||||||
@Positive
|
@Positive
|
||||||
private double width;
|
private final double width;
|
||||||
|
|
||||||
@Positive
|
@Positive
|
||||||
private double height;
|
private final double height;
|
||||||
|
|
||||||
public CanvasSettingsRequest() {}
|
|
||||||
|
|
||||||
public CanvasSettingsRequest(double width, double height) {
|
public CanvasSettingsRequest(double width, double height) {
|
||||||
this.width = width;
|
this.width = width;
|
||||||
@@ -21,15 +19,8 @@ public class CanvasSettingsRequest {
|
|||||||
return width;
|
return width;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setWidth(double width) {
|
|
||||||
this.width = width;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getHeight() {
|
public double getHeight() {
|
||||||
return height;
|
return height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setHeight(double height) {
|
|
||||||
this.height = height;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
public class ChannelScriptSettingsRequest {
|
public class ChannelScriptSettingsRequest {
|
||||||
|
|
||||||
@@ -6,8 +6,6 @@ public class ChannelScriptSettingsRequest {
|
|||||||
private boolean allowSevenTvEmotesForAssets = true;
|
private boolean allowSevenTvEmotesForAssets = true;
|
||||||
private boolean allowScriptChatAccess = true;
|
private boolean allowScriptChatAccess = true;
|
||||||
|
|
||||||
public ChannelScriptSettingsRequest() {}
|
|
||||||
|
|
||||||
public ChannelScriptSettingsRequest(
|
public ChannelScriptSettingsRequest(
|
||||||
boolean allowChannelEmotesForAssets,
|
boolean allowChannelEmotesForAssets,
|
||||||
boolean allowSevenTvEmotesForAssets,
|
boolean allowSevenTvEmotesForAssets,
|
||||||
@@ -22,23 +20,12 @@ public class ChannelScriptSettingsRequest {
|
|||||||
return allowChannelEmotesForAssets;
|
return allowChannelEmotesForAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAllowChannelEmotesForAssets(boolean allowChannelEmotesForAssets) {
|
|
||||||
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isAllowSevenTvEmotesForAssets() {
|
public boolean isAllowSevenTvEmotesForAssets() {
|
||||||
return allowSevenTvEmotesForAssets;
|
return allowSevenTvEmotesForAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) {
|
|
||||||
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isAllowScriptChatAccess() {
|
public boolean isAllowScriptChatAccess() {
|
||||||
return allowScriptChatAccess;
|
return allowScriptChatAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAllowScriptChatAccess(boolean allowScriptChatAccess) {
|
|
||||||
this.allowScriptChatAccess = allowScriptChatAccess;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
|
import jakarta.annotation.Nullable;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
public class CodeAssetRequest {
|
public class CodeAssetRequest {
|
||||||
@@ -12,6 +13,7 @@ public class CodeAssetRequest {
|
|||||||
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
private Boolean isPublic;
|
private Boolean isPublic;
|
||||||
|
|
||||||
private java.util.List<String> allowedDomains;
|
private java.util.List<String> allowedDomains;
|
||||||
@@ -40,14 +42,11 @@ public class CodeAssetRequest {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
public Boolean getIsPublic() {
|
public Boolean getIsPublic() {
|
||||||
return isPublic;
|
return isPublic;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setIsPublic(Boolean isPublic) {
|
|
||||||
this.isPublic = isPublic;
|
|
||||||
}
|
|
||||||
|
|
||||||
public java.util.List<String> getAllowedDomains() {
|
public java.util.List<String> getAllowedDomains() {
|
||||||
return allowedDomains;
|
return allowedDomains;
|
||||||
}
|
}
|
||||||
@@ -55,4 +54,8 @@ public class CodeAssetRequest {
|
|||||||
public void setAllowedDomains(java.util.List<String> allowedDomains) {
|
public void setAllowedDomains(java.util.List<String> allowedDomains) {
|
||||||
this.allowedDomains = allowedDomains;
|
this.allowedDomains = allowedDomains;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setPublic(@Nullable Boolean aPublic) {
|
||||||
|
isPublic = aPublic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
public class PlaybackRequest {
|
public class PlaybackRequest {
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record ScriptMarketplaceImportRequest(@NotBlank String targetBroadcaster) { }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
import jakarta.validation.constraints.DecimalMax;
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
import jakarta.validation.constraints.DecimalMin;
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
@@ -112,34 +112,18 @@ public class TransformRequest {
|
|||||||
return audioLoop;
|
return audioLoop;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAudioLoop(Boolean audioLoop) {
|
|
||||||
this.audioLoop = audioLoop;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getAudioDelayMillis() {
|
public Integer getAudioDelayMillis() {
|
||||||
return audioDelayMillis;
|
return audioDelayMillis;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAudioDelayMillis(Integer audioDelayMillis) {
|
|
||||||
this.audioDelayMillis = audioDelayMillis;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Double getAudioSpeed() {
|
public Double getAudioSpeed() {
|
||||||
return audioSpeed;
|
return audioSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAudioSpeed(Double audioSpeed) {
|
|
||||||
this.audioSpeed = audioSpeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Double getAudioPitch() {
|
public Double getAudioPitch() {
|
||||||
return audioPitch;
|
return audioPitch;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAudioPitch(Double audioPitch) {
|
|
||||||
this.audioPitch = audioPitch;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Double getAudioVolume() {
|
public Double getAudioVolume() {
|
||||||
return audioVolume;
|
return audioVolume;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.request;
|
||||||
|
|
||||||
public class VisibilityRequest {
|
public class VisibilityRequest {
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ public class AssetEvent {
|
|||||||
UPDATED,
|
UPDATED,
|
||||||
VISIBILITY,
|
VISIBILITY,
|
||||||
PLAY,
|
PLAY,
|
||||||
|
PREVIEW,
|
||||||
DELETED,
|
DELETED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +68,15 @@ public class AssetEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AssetEvent preview(String channel, String assetId, AssetPatch patch) {
|
||||||
|
AssetEvent event = new AssetEvent();
|
||||||
|
event.type = Type.PREVIEW;
|
||||||
|
event.channel = channel;
|
||||||
|
event.patch = patch;
|
||||||
|
event.assetId = assetId;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
public static AssetEvent deleted(String channel, String assetId) {
|
public static AssetEvent deleted(String channel, String assetId) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.DELETED;
|
event.type = Type.DELETED;
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a partial update for an {@link Asset}. Only the fields that changed
|
* Represents a partial update for an {@link dev.kruhlmann.imgfloat.model.db.imgfloat.Asset}. Only the fields that changed
|
||||||
* for a given operation are populated to reduce payload sizes sent over WebSocket.
|
* for a given operation are populated to reduce payload sizes sent over WebSocket.
|
||||||
*/
|
*/
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
|
||||||
|
|
||||||
public record AssetView(
|
public record AssetView(
|
||||||
String id,
|
String id,
|
||||||
String broadcaster,
|
String broadcaster,
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
|
||||||
|
|
||||||
public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) {
|
public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) {
|
||||||
public static AuditLogEntryView fromEntry(AuditLogEntry entry) {
|
public static AuditLogEntryView fromEntry(AuditLogEntry entry) {
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
|
||||||
public class CanvasEvent {
|
public class CanvasEvent {
|
||||||
|
|
||||||
public enum Type {
|
public enum Type {
|
||||||
@@ -18,15 +19,4 @@ public class CanvasEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Type getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getChannel() {
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CanvasSettingsRequest getPayload() {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
public record ErrorResponse(int status, String message, String path) {}
|
public record ErrorResponse(int status, String message, String path) {}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
|
||||||
public record ScriptAssetAttachmentView(
|
public record ScriptAssetAttachmentView(
|
||||||
String id,
|
String id,
|
||||||
String scriptAssetId,
|
String scriptAssetId,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
public record ScriptMarketplaceEntry(
|
public record ScriptMarketplaceEntry(
|
||||||
String id,
|
String id,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.api.response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Twitch user details used for rendering avatars and display names.
|
* Minimal Twitch user details used for rendering avatars and display names.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.audit.model;
|
package dev.kruhlmann.imgfloat.model.db.audit;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -12,6 +12,8 @@ import java.time.Instant;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "assets")
|
@Table(name = "assets")
|
||||||
public class Asset {
|
public class Asset {
|
||||||
@@ -81,26 +83,14 @@ public class Asset {
|
|||||||
return assetType == null ? AssetType.OTHER : assetType;
|
return assetType == null ? AssetType.OTHER : assetType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setAssetType(AssetType assetType) {
|
|
||||||
this.assetType = assetType == null ? AssetType.OTHER : assetType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setCreatedAt(Instant createdAt) {
|
|
||||||
this.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getUpdatedAt() {
|
public Instant getUpdatedAt() {
|
||||||
return updatedAt;
|
return updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUpdatedAt(Instant updatedAt) {
|
|
||||||
this.updatedAt = updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getDisplayOrder() {
|
public Integer getDisplayOrder() {
|
||||||
return displayOrder;
|
return displayOrder;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.CollectionTable;
|
import jakarta.persistence.CollectionTable;
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
@@ -15,7 +15,6 @@ import java.util.Collections;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "channels")
|
@Table(name = "channels")
|
||||||
@@ -27,7 +26,7 @@ public class Channel {
|
|||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
|
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
|
||||||
@Column(name = "admin_username")
|
@Column(name = "admin_username")
|
||||||
private Set<String> admins = new HashSet<>();
|
private final Set<String> admins = new HashSet<>();
|
||||||
|
|
||||||
private double canvasWidth = 1920;
|
private double canvasWidth = 1920;
|
||||||
|
|
||||||
@@ -52,8 +51,6 @@ public class Channel {
|
|||||||
|
|
||||||
public Channel(String broadcaster) {
|
public Channel(String broadcaster) {
|
||||||
this.broadcaster = normalize(broadcaster);
|
this.broadcaster = normalize(broadcaster);
|
||||||
this.canvasWidth = 1920;
|
|
||||||
this.canvasHeight = 1080;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getBroadcaster() {
|
public String getBroadcaster() {
|
||||||
@@ -112,24 +109,6 @@ public class Channel {
|
|||||||
this.allowScriptChatAccess = allowScriptChatAccess;
|
this.allowScriptChatAccess = allowScriptChatAccess;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PrePersist
|
|
||||||
@PreUpdate
|
|
||||||
public void normalizeFields() {
|
|
||||||
Instant now = Instant.now();
|
|
||||||
if (createdAt == null) {
|
|
||||||
createdAt = now;
|
|
||||||
}
|
|
||||||
updatedAt = now;
|
|
||||||
this.broadcaster = normalize(broadcaster);
|
|
||||||
this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
|
|
||||||
if (canvasWidth <= 0) {
|
|
||||||
canvasWidth = 1920;
|
|
||||||
}
|
|
||||||
if (canvasHeight <= 0) {
|
|
||||||
canvasHeight = 1080;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
@@ -138,6 +117,22 @@ public class Channel {
|
|||||||
return updatedAt;
|
return updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
private void ensureCreatedAt() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = now;
|
||||||
|
}
|
||||||
|
if (updatedAt == null) {
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
private void touchUpdatedAt() {
|
||||||
|
updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
private static String normalize(String value) {
|
private static String normalize(String value) {
|
||||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -30,10 +30,6 @@ public class MarketplaceScriptHeart {
|
|||||||
return scriptId;
|
return scriptId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setScriptId(String scriptId) {
|
|
||||||
this.scriptId = scriptId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
public String getUsername() {
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.CollectionTable;
|
import jakarta.persistence.CollectionTable;
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
@@ -14,6 +14,8 @@ import jakarta.persistence.Transient;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "script_assets")
|
@Table(name = "script_assets")
|
||||||
public class ScriptAsset {
|
public class ScriptAsset {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -10,6 +10,8 @@ import jakarta.persistence.PreUpdate;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "script_asset_attachments")
|
@Table(name = "script_asset_attachments")
|
||||||
public class ScriptAssetAttachment {
|
public class ScriptAssetAttachment {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -11,6 +11,8 @@ import jakarta.persistence.Table;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "script_asset_files")
|
@Table(name = "script_asset_files")
|
||||||
public class ScriptAssetFile {
|
public class ScriptAssetFile {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -43,6 +43,9 @@ public class Settings {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int emoteSyncIntervalMinutes;
|
private int emoteSyncIntervalMinutes;
|
||||||
|
|
||||||
|
@Column(name = "last_emote_sync_at")
|
||||||
|
private Instant lastEmoteSyncAt;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ public class Settings {
|
|||||||
s.setMaxCanvasSideLengthPixels(7680);
|
s.setMaxCanvasSideLengthPixels(7680);
|
||||||
s.setCanvasFramesPerSecond(60);
|
s.setCanvasFramesPerSecond(60);
|
||||||
s.setEmoteSyncIntervalMinutes(60);
|
s.setEmoteSyncIntervalMinutes(60);
|
||||||
|
s.setLastEmoteSyncAt(null);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +149,14 @@ public class Settings {
|
|||||||
this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes;
|
this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getLastEmoteSyncAt() {
|
||||||
|
return lastEmoteSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastEmoteSyncAt(Instant lastEmoteSyncAt) {
|
||||||
|
this.lastEmoteSyncAt = lastEmoteSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
public void initializeTimestamps() {
|
public void initializeTimestamps() {
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Asset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.AudioAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeart;
|
||||||
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeartId;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeartId;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetFile;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}
|
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.SystemAdministrator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository;
|
package dev.kruhlmann.imgfloat.repository;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.VisualAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.repository.audit;
|
package dev.kruhlmann.imgfloat.repository.audit;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry;
|
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -11,7 +10,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> {
|
public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> {
|
||||||
List<AuditLogEntry> findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster);
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Asset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetFile;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
|
||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
|
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Asset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
|
||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class AssetStorageService {
|
|||||||
StandardOpenOption.TRUNCATE_EXISTING,
|
StandardOpenOption.TRUNCATE_EXISTING,
|
||||||
StandardOpenOption.WRITE
|
StandardOpenOption.WRITE
|
||||||
);
|
);
|
||||||
logger.info("Wrote asset to {}", file);
|
logger.info("Wrote asset preview to {}", file);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<AssetContent> loadAssetFile(String broadcaster, String assetId, String mediaType) {
|
public Optional<AssetContent> loadAssetFile(String broadcaster, String assetId, String mediaType) {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry;
|
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
|
||||||
import dev.kruhlmann.imgfloat.model.AuditLogEntryView;
|
|
||||||
import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository;
|
import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository;
|
||||||
import dev.kruhlmann.imgfloat.util.LogSanitizer;
|
import dev.kruhlmann.imgfloat.util.LogSanitizer;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -54,18 +52,6 @@ public class AuditLogService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AuditLogEntryView> listEntries(String broadcaster) {
|
|
||||||
String normalizedBroadcaster = normalize(broadcaster);
|
|
||||||
if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
return auditLogRepository
|
|
||||||
.findTop200ByBroadcasterOrderByCreatedAtDesc(normalizedBroadcaster)
|
|
||||||
.stream()
|
|
||||||
.map(AuditLogEntryView::fromEntry)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Page<AuditLogEntry> listEntries(
|
public Page<AuditLogEntry> listEntries(
|
||||||
String broadcaster,
|
String broadcaster,
|
||||||
String actor,
|
String actor,
|
||||||
|
|||||||
@@ -3,28 +3,30 @@ package dev.kruhlmann.imgfloat.service;
|
|||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Asset;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetEvent;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetPatch;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetType;
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.AudioAsset;
|
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.CanvasEvent;
|
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
|
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
import dev.kruhlmann.imgfloat.model.api.response.AssetPatch;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView;
|
import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAssetFile;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||||
import dev.kruhlmann.imgfloat.model.VisualAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeart;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
|
||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
@@ -65,6 +67,12 @@ public class ChannelDirectoryService {
|
|||||||
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
private static final String DEFAULT_CODE_MEDIA_TYPE = "application/javascript";
|
||||||
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
|
private static final int MAX_ALLOWED_SCRIPT_DOMAINS = 32;
|
||||||
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
private static final Pattern ALLOWED_DOMAIN_PATTERN = Pattern.compile("^[a-z0-9.-]+(?::[0-9]{1,5})?$");
|
||||||
|
private static final EnumSet<AssetType> VISUAL_ASSET_TYPES = EnumSet.of(
|
||||||
|
AssetType.IMAGE,
|
||||||
|
AssetType.VIDEO,
|
||||||
|
AssetType.MODEL,
|
||||||
|
AssetType.OTHER
|
||||||
|
);
|
||||||
|
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
@@ -581,14 +589,14 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Optional<AssetView> clearScriptLogo(String broadcaster, String assetId, String actor) {
|
public void clearScriptLogo(String broadcaster, String assetId, String actor) {
|
||||||
Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId);
|
Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId);
|
||||||
ScriptAsset script = scriptAssetRepository
|
ScriptAsset script = scriptAssetRepository
|
||||||
.findById(asset.getId())
|
.findById(asset.getId())
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||||
String previousLogoFileId = script.getLogoFileId();
|
String previousLogoFileId = script.getLogoFileId();
|
||||||
if (previousLogoFileId == null) {
|
if (previousLogoFileId == null) {
|
||||||
return Optional.empty();
|
return;
|
||||||
}
|
}
|
||||||
script.setLogoFileId(null);
|
script.setLogoFileId(null);
|
||||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||||
@@ -602,7 +610,6 @@ public class ChannelDirectoryService {
|
|||||||
"SCRIPT_LOGO_CLEARED",
|
"SCRIPT_LOGO_CLEARED",
|
||||||
"Cleared script logo for " + script.getName() + " (" + asset.getId() + ")"
|
"Cleared script logo for " + script.getName() + " (" + asset.getId() + ")"
|
||||||
);
|
);
|
||||||
return Optional.of(view);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
|
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
|
||||||
@@ -804,7 +811,7 @@ public class ChannelDirectoryService {
|
|||||||
.findById(scriptId)
|
.findById(scriptId)
|
||||||
.filter(ScriptAsset::isPublic)
|
.filter(ScriptAsset::isPublic)
|
||||||
.map(ScriptAsset::getLogoFileId)
|
.map(ScriptAsset::getLogoFileId)
|
||||||
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId))
|
.flatMap(scriptAssetFileRepository::findById)
|
||||||
.flatMap((file) ->
|
.flatMap((file) ->
|
||||||
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
|
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
|
||||||
);
|
);
|
||||||
@@ -1065,7 +1072,7 @@ public class ChannelDirectoryService {
|
|||||||
.findById(asset.getId())
|
.findById(asset.getId())
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||||
Integer beforeOrder = asset.getDisplayOrder();
|
Integer beforeOrder = asset.getDisplayOrder();
|
||||||
List<Asset> orderUpdates = List.of();
|
List<Asset> orderUpdates;
|
||||||
if (req.getOrder() != null) {
|
if (req.getOrder() != null) {
|
||||||
if (req.getOrder() < 1) {
|
if (req.getOrder() < 1) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
||||||
@@ -1158,12 +1165,214 @@ public class ChannelDirectoryService {
|
|||||||
formatVisualTransformDetails(asset.getId(), req)
|
formatVisualTransformDetails(asset.getId(), req)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
|
publishOrderUpdates(broadcaster, asset.getId(), orderUpdates);
|
||||||
return view;
|
return view;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AssetPatch> previewTransform(String broadcaster, String assetId, TransformRequest request) {
|
||||||
|
String normalized = normalize(broadcaster);
|
||||||
|
|
||||||
|
Asset asset = assetRepository
|
||||||
|
.findById(assetId)
|
||||||
|
.filter((stored) -> normalized.equals(stored.getBroadcaster()))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset not found"));
|
||||||
|
|
||||||
|
if (!VISUAL_ASSET_TYPES.contains(asset.getAssetType())) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Asset is not visual");
|
||||||
|
}
|
||||||
|
|
||||||
|
VisualAsset visual = visualAssetRepository
|
||||||
|
.findById(asset.getId())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not visual"));
|
||||||
|
|
||||||
|
TransformRequest previewRequest = copyVisualTransformRequest(request);
|
||||||
|
validateVisualBounds(previewRequest);
|
||||||
|
|
||||||
|
AssetPatch.VisualSnapshot before = new AssetPatch.VisualSnapshot(
|
||||||
|
visual.getX(),
|
||||||
|
visual.getY(),
|
||||||
|
visual.getWidth(),
|
||||||
|
visual.getHeight(),
|
||||||
|
visual.getRotation(),
|
||||||
|
visual.getSpeed(),
|
||||||
|
visual.isMuted(),
|
||||||
|
displayOrderValue(asset),
|
||||||
|
visual.getAudioVolume()
|
||||||
|
);
|
||||||
|
|
||||||
|
VisualAsset previewState = new VisualAsset();
|
||||||
|
previewState.setId(visual.getId());
|
||||||
|
previewState.setName(visual.getName());
|
||||||
|
previewState.setX(visual.getX());
|
||||||
|
previewState.setY(visual.getY());
|
||||||
|
previewState.setWidth(visual.getWidth());
|
||||||
|
previewState.setHeight(visual.getHeight());
|
||||||
|
previewState.setRotation(visual.getRotation());
|
||||||
|
previewState.setSpeed(visual.getSpeed());
|
||||||
|
previewState.setMuted(visual.isMuted());
|
||||||
|
previewState.setAudioVolume(visual.getAudioVolume());
|
||||||
|
|
||||||
|
if (previewRequest.getX() != null) {
|
||||||
|
previewState.setX(previewRequest.getX());
|
||||||
|
}
|
||||||
|
if (previewRequest.getY() != null) {
|
||||||
|
previewState.setY(previewRequest.getY());
|
||||||
|
}
|
||||||
|
if (previewRequest.getWidth() != null) {
|
||||||
|
previewState.setWidth(previewRequest.getWidth());
|
||||||
|
}
|
||||||
|
if (previewRequest.getHeight() != null) {
|
||||||
|
previewState.setHeight(previewRequest.getHeight());
|
||||||
|
}
|
||||||
|
if (previewRequest.getRotation() != null) {
|
||||||
|
previewState.setRotation(previewRequest.getRotation());
|
||||||
|
}
|
||||||
|
if (previewRequest.getSpeed() != null) {
|
||||||
|
previewState.setSpeed(previewRequest.getSpeed());
|
||||||
|
}
|
||||||
|
if (previewRequest.getMuted() != null) {
|
||||||
|
previewState.setMuted(previewRequest.getMuted());
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetPatch patch = AssetPatch.fromVisualTransform(before, previewState, previewRequest);
|
||||||
|
return hasPatchChanges(patch) ? Optional.of(patch) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void reorderAssets(
|
||||||
|
String broadcaster,
|
||||||
|
List<AssetOrderRequest.AssetOrderUpdate> updates,
|
||||||
|
String actor
|
||||||
|
) {
|
||||||
|
if (updates == null || updates.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String normalized = normalize(broadcaster);
|
||||||
|
applyBulkOrderUpdates(
|
||||||
|
broadcaster,
|
||||||
|
normalized,
|
||||||
|
updates,
|
||||||
|
EnumSet.of(AssetType.SCRIPT),
|
||||||
|
actor,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
applyBulkOrderUpdates(
|
||||||
|
broadcaster,
|
||||||
|
normalized,
|
||||||
|
updates,
|
||||||
|
EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER),
|
||||||
|
actor,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyBulkOrderUpdates(
|
||||||
|
String broadcaster,
|
||||||
|
String normalized,
|
||||||
|
List<AssetOrderRequest.AssetOrderUpdate> updates,
|
||||||
|
EnumSet<AssetType> types,
|
||||||
|
String actor,
|
||||||
|
boolean script
|
||||||
|
) {
|
||||||
|
if (updates == null || updates.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Asset> bucket = assetRepository
|
||||||
|
.findByBroadcaster(normalized)
|
||||||
|
.stream()
|
||||||
|
.filter((asset) -> types.contains(asset.getAssetType()))
|
||||||
|
.sorted(
|
||||||
|
Comparator.comparingInt(this::displayOrderValue)
|
||||||
|
.reversed()
|
||||||
|
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (bucket.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Integer> desiredOrder = new HashMap<>();
|
||||||
|
Set<String> bucketIds = bucket.stream().map(Asset::getId).collect(Collectors.toSet());
|
||||||
|
for (AssetOrderRequest.AssetOrderUpdate update : updates) {
|
||||||
|
if (update == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String assetId = update.assetId();
|
||||||
|
if (!bucketIds.contains(assetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Integer order = update.order();
|
||||||
|
if (order == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (order < 1) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
||||||
|
}
|
||||||
|
desiredOrder.put(assetId, order);
|
||||||
|
}
|
||||||
|
if (desiredOrder.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Integer> originalIndex = new HashMap<>();
|
||||||
|
for (int index = 0; index < bucket.size(); index++) {
|
||||||
|
originalIndex.put(bucket.get(index).getId(), index);
|
||||||
|
}
|
||||||
|
List<Asset> ordered = new ArrayList<>(bucket);
|
||||||
|
ordered.sort((a, b) -> {
|
||||||
|
int orderA = desiredOrder.getOrDefault(a.getId(), a.getDisplayOrder() != null ? a.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(a.getId(), bucket.size()));
|
||||||
|
int orderB = desiredOrder.getOrDefault(b.getId(), b.getDisplayOrder() != null ? b.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(b.getId(), bucket.size()));
|
||||||
|
int cmp = Integer.compare(orderB, orderA);
|
||||||
|
if (cmp != 0) {
|
||||||
|
return cmp;
|
||||||
|
}
|
||||||
|
return Integer.compare(originalIndex.getOrDefault(a.getId(), Integer.MAX_VALUE), originalIndex.getOrDefault(b.getId(), Integer.MAX_VALUE));
|
||||||
|
});
|
||||||
|
List<Asset> changed = new ArrayList<>();
|
||||||
|
for (int index = 0; index < ordered.size(); index++) {
|
||||||
|
Asset asset = ordered.get(index);
|
||||||
|
int nextOrder = ordered.size() - index;
|
||||||
|
if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) {
|
||||||
|
asset.setDisplayOrder(nextOrder);
|
||||||
|
changed.add(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assetRepository.saveAll(changed);
|
||||||
|
publishOrderUpdates(broadcaster, null, changed);
|
||||||
|
for (Asset asset : changed) {
|
||||||
|
if (script) {
|
||||||
|
auditLogService.recordEntry(
|
||||||
|
asset.getBroadcaster(),
|
||||||
|
actor,
|
||||||
|
"SCRIPT_ORDER_UPDATED",
|
||||||
|
formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
TransformRequest logDetails = new TransformRequest();
|
||||||
|
logDetails.setOrder(asset.getDisplayOrder());
|
||||||
|
auditLogService.recordEntry(
|
||||||
|
asset.getBroadcaster(),
|
||||||
|
actor,
|
||||||
|
"VISUAL_UPDATED",
|
||||||
|
formatVisualTransformDetails(asset.getId(), logDetails)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateVisualTransform(TransformRequest req) {
|
private void validateVisualTransform(TransformRequest req) {
|
||||||
|
validateVisualBounds(req);
|
||||||
|
if (req.getOrder() != null && req.getOrder() < 1) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateVisualBounds(TransformRequest req) {
|
||||||
|
if (req == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Settings settings = settingsService.get();
|
Settings settings = settingsService.get();
|
||||||
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
|
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
|
||||||
double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
|
double minSpeed = settings.getMinAssetPlaybackSpeedFraction();
|
||||||
@@ -1172,30 +1381,49 @@ public class ChannelDirectoryService {
|
|||||||
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
|
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
req.getWidth() == null || req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels
|
req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
BAD_REQUEST,
|
throw new ResponseStatusException(
|
||||||
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
BAD_REQUEST,
|
||||||
);
|
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
req.getHeight() == null || req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels
|
req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
BAD_REQUEST,
|
throw new ResponseStatusException(
|
||||||
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
BAD_REQUEST,
|
||||||
);
|
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
||||||
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
) {
|
||||||
if (req.getOrder() != null && req.getOrder() < 1) throw new ResponseStatusException(
|
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||||
BAD_REQUEST,
|
}
|
||||||
"Order must be >= 1"
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
||||||
) throw new ResponseStatusException(
|
) {
|
||||||
BAD_REQUEST,
|
throw new ResponseStatusException(
|
||||||
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
|
BAD_REQUEST,
|
||||||
);
|
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TransformRequest copyVisualTransformRequest(TransformRequest source) {
|
||||||
|
TransformRequest copy = new TransformRequest();
|
||||||
|
if (source == null) {
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
copy.setX(source.getX());
|
||||||
|
copy.setY(source.getY());
|
||||||
|
copy.setWidth(source.getWidth());
|
||||||
|
copy.setHeight(source.getHeight());
|
||||||
|
copy.setRotation(source.getRotation());
|
||||||
|
copy.setSpeed(source.getSpeed());
|
||||||
|
copy.setMuted(source.getMuted());
|
||||||
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateAudioTransform(TransformRequest req) {
|
private void validateAudioTransform(TransformRequest req) {
|
||||||
@@ -1514,7 +1742,7 @@ public class ChannelDirectoryService {
|
|||||||
return scriptAssetRepository
|
return scriptAssetRepository
|
||||||
.findById(asset.getId())
|
.findById(asset.getId())
|
||||||
.map(ScriptAsset::getLogoFileId)
|
.map(ScriptAsset::getLogoFileId)
|
||||||
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId))
|
.flatMap(scriptAssetFileRepository::findById)
|
||||||
.flatMap((file) ->
|
.flatMap((file) ->
|
||||||
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
|
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
|
||||||
);
|
);
|
||||||
@@ -1723,7 +1951,7 @@ public class ChannelDirectoryService {
|
|||||||
return assets
|
return assets
|
||||||
.stream()
|
.stream()
|
||||||
.sorted(
|
.sorted(
|
||||||
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
|
Comparator.comparingInt(this::displayOrderValue)
|
||||||
.reversed()
|
.reversed()
|
||||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||||
)
|
)
|
||||||
@@ -1761,7 +1989,7 @@ public class ChannelDirectoryService {
|
|||||||
.stream()
|
.stream()
|
||||||
.filter((asset) -> types.contains(asset.getAssetType()))
|
.filter((asset) -> types.contains(asset.getAssetType()))
|
||||||
.sorted(
|
.sorted(
|
||||||
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
|
Comparator.comparingInt(this::displayOrderValue)
|
||||||
.reversed()
|
.reversed()
|
||||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -50,13 +50,22 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
|
|||||||
|
|
||||||
private Trigger buildTrigger() {
|
private Trigger buildTrigger() {
|
||||||
return (TriggerContext triggerContext) -> {
|
return (TriggerContext triggerContext) -> {
|
||||||
Instant lastCompletion = triggerContext.lastCompletionTime() == null
|
int interval = resolveIntervalMinutes();
|
||||||
? Instant.now()
|
Instant lastCompletion = resolveLastCompletion(triggerContext, interval);
|
||||||
: triggerContext.lastCompletionTime().toInstant();
|
return lastCompletion.plus(Duration.ofMinutes(interval));
|
||||||
return lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes()));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Instant resolveLastCompletion(TriggerContext triggerContext, int intervalMinutes) {
|
||||||
|
Instant lastCompletion = triggerContext.lastCompletion();
|
||||||
|
if (lastCompletion != null) {
|
||||||
|
return lastCompletion;
|
||||||
|
}
|
||||||
|
Settings settings = settingsService.get();
|
||||||
|
Instant persisted = settings.getLastEmoteSyncAt();
|
||||||
|
return persisted != null ? persisted : Instant.now().minus(Duration.ofMinutes(intervalMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
private int resolveIntervalMinutes() {
|
private int resolveIntervalMinutes() {
|
||||||
Settings settings = settingsService.get();
|
Settings settings = settingsService.get();
|
||||||
int interval = settings.getEmoteSyncIntervalMinutes();
|
int interval = settings.getEmoteSyncIntervalMinutes();
|
||||||
@@ -66,15 +75,20 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
|
|||||||
private void syncEmotes() {
|
private void syncEmotes() {
|
||||||
int interval = resolveIntervalMinutes();
|
int interval = resolveIntervalMinutes();
|
||||||
LOG.info("Synchronizing emotes (interval {} minutes)", interval);
|
LOG.info("Synchronizing emotes (interval {} minutes)", interval);
|
||||||
|
List<Channel> channels = List.of();
|
||||||
twitchEmoteService.refreshGlobalEmotes();
|
try {
|
||||||
List<Channel> channels = channelRepository.findAll();
|
channels = channelRepository.findAll();
|
||||||
for (Channel channel : channels) {
|
twitchEmoteService.refreshGlobalEmotes();
|
||||||
String broadcaster = channel.getBroadcaster();
|
for (Channel channel : channels) {
|
||||||
twitchEmoteService.refreshChannelEmotes(broadcaster);
|
String broadcaster = channel.getBroadcaster();
|
||||||
sevenTvEmoteService.refreshChannelEmotes(broadcaster);
|
twitchEmoteService.refreshChannelEmotes(broadcaster);
|
||||||
|
sevenTvEmoteService.refreshChannelEmotes(broadcaster);
|
||||||
|
}
|
||||||
|
LOG.info("Completed emote sync for {} channels", channels.size());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.error("Emote sync failed", ex);
|
||||||
|
} finally {
|
||||||
|
settingsService.updateLastEmoteSyncAt(Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.info("Completed emote sync for {} channels", channels.size());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ public class GitInfoService {
|
|||||||
this.commitUrlPrefix = normalize(commitUrlPrefix);
|
this.commitUrlPrefix = normalize(commitUrlPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCommitSha() {
|
|
||||||
return commitSha;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getShortCommitSha() {
|
public String getShortCommitSha() {
|
||||||
return shortCommitSha;
|
return shortCommitSha;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
|
||||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -134,10 +134,7 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
if (!Files.isDirectory(scriptDir)) {
|
if (!Files.isDirectory(scriptDir)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
SeedScript script = loadScriptDirectory(scriptDir).orElse(null);
|
loadScriptDirectory(scriptDir).ifPresent(loaded::add);
|
||||||
if (script != null) {
|
|
||||||
loaded.add(script);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
logger.warn("Failed to read marketplace script directory {}", rootPath, ex);
|
logger.warn("Failed to read marketplace script directory {}", rootPath, ex);
|
||||||
@@ -179,7 +176,7 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
broadcaster,
|
broadcaster,
|
||||||
sourceMediaType,
|
sourceMediaType,
|
||||||
logoMediaType,
|
logoMediaType,
|
||||||
Optional.ofNullable(sourcePath),
|
Optional.of(sourcePath),
|
||||||
Optional.ofNullable(logoPath),
|
Optional.ofNullable(logoPath),
|
||||||
allowedDomains,
|
allowedDomains,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -207,7 +204,7 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
attachments.add(
|
attachments.add(
|
||||||
new SeedAttachment(
|
new SeedAttachment(
|
||||||
name,
|
name,
|
||||||
mediaType == null ? "application/octet-stream" : mediaType,
|
mediaType,
|
||||||
attachment,
|
attachment,
|
||||||
new AtomicReference<>()
|
new AtomicReference<>()
|
||||||
)
|
)
|
||||||
@@ -348,7 +345,7 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String content = Files.readString(path);
|
String content = Files.readString(path);
|
||||||
return JsonSupport.read(content, ScriptSeedMetadata.class);
|
return JsonSupport.read(content);
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
logger.warn("Failed to read marketplace metadata {}", path, ex);
|
logger.warn("Failed to read marketplace metadata {}", path, ex);
|
||||||
return null;
|
return null;
|
||||||
@@ -362,8 +359,8 @@ public class MarketplaceScriptSeedLoader {
|
|||||||
|
|
||||||
private JsonSupport() {}
|
private JsonSupport() {}
|
||||||
|
|
||||||
static <T> T read(String payload, Class<T> type) throws IOException {
|
static <T> T read(String payload) throws IOException {
|
||||||
return mapper().readValue(payload, type);
|
return mapper().readValue(payload, (Class<T>) ScriptSeedMetadata.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static com.fasterxml.jackson.databind.ObjectMapper mapper() {
|
private static com.fasterxml.jackson.databind.ObjectMapper mapper() {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package dev.kruhlmann.imgfloat.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.kruhlmann.imgfloat.model.AudioAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import dev.kruhlmann.imgfloat.model.VisualAsset;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
|
||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -59,6 +60,12 @@ public class SettingsService {
|
|||||||
return savedSettings;
|
return savedSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updateLastEmoteSyncAt(Instant timestamp) {
|
||||||
|
Settings settings = get();
|
||||||
|
settings.setLastEmoteSyncAt(timestamp);
|
||||||
|
repo.save(settings);
|
||||||
|
}
|
||||||
|
|
||||||
public void logSettings(String msg, Settings settings) {
|
public void logSettings(String msg, Settings settings) {
|
||||||
try {
|
try {
|
||||||
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
|
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.SystemAdministrator;
|
||||||
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
|
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ public class TwitchAppAccessTokenService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
AccessToken current = cachedToken;
|
AccessToken current = cachedToken;
|
||||||
if (current != null && !current.isExpired()) {
|
if (current != null && current.isActive()) {
|
||||||
return Optional.of(current.token());
|
return Optional.of(current.token());
|
||||||
}
|
}
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
AccessToken refreshed = cachedToken;
|
AccessToken refreshed = cachedToken;
|
||||||
if (refreshed != null && !refreshed.isExpired()) {
|
if (refreshed != null && refreshed.isActive()) {
|
||||||
return Optional.of(refreshed.token());
|
return Optional.of(refreshed.token());
|
||||||
}
|
}
|
||||||
cachedToken = requestToken();
|
cachedToken = requestToken();
|
||||||
@@ -91,8 +91,8 @@ public class TwitchAppAccessTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private record AccessToken(String token, Instant expiresAt) {
|
private record AccessToken(String token, Instant expiresAt) {
|
||||||
boolean isExpired() {
|
boolean isActive() {
|
||||||
return expiresAt == null || expiresAt.isBefore(Instant.now());
|
return expiresAt != null && !expiresAt.isBefore(Instant.now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -22,6 +24,8 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -41,6 +45,7 @@ public class TwitchEmoteService {
|
|||||||
private final Map<String, CachedEmote> emoteCache = new ConcurrentHashMap<>();
|
private final Map<String, CachedEmote> emoteCache = new ConcurrentHashMap<>();
|
||||||
private final Map<String, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>();
|
private final Map<String, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>();
|
||||||
private volatile List<CachedEmote> globalEmotes = List.of();
|
private volatile List<CachedEmote> globalEmotes = List.of();
|
||||||
|
private final AtomicBoolean initialGlobalSyncScheduled = new AtomicBoolean();
|
||||||
|
|
||||||
public TwitchEmoteService(
|
public TwitchEmoteService(
|
||||||
RestTemplateBuilder builder,
|
RestTemplateBuilder builder,
|
||||||
@@ -61,12 +66,11 @@ public class TwitchEmoteService {
|
|||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
throw new IllegalStateException("Failed to create Twitch emote cache directory", ex);
|
throw new IllegalStateException("Failed to create Twitch emote cache directory", ex);
|
||||||
}
|
}
|
||||||
warmGlobalEmotes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<EmoteDescriptor> getGlobalEmotes() {
|
public List<EmoteDescriptor> getGlobalEmotes() {
|
||||||
if (globalEmotes.isEmpty()) {
|
if (globalEmotes.isEmpty()) {
|
||||||
warmGlobalEmotes();
|
ensureInitialGlobalSyncScheduled();
|
||||||
}
|
}
|
||||||
return globalEmotes.stream().map(CachedEmote::descriptor).toList();
|
return globalEmotes.stream().map(CachedEmote::descriptor).toList();
|
||||||
}
|
}
|
||||||
@@ -127,6 +131,31 @@ public class TwitchEmoteService {
|
|||||||
LOG.info("Loaded {} global Twitch emotes", cached.size());
|
LOG.info("Loaded {} global Twitch emotes", cached.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureInitialGlobalSyncScheduled() {
|
||||||
|
if (initialGlobalSyncScheduled.compareAndSet(false, true)) {
|
||||||
|
LOG.info("Scheduling initial global Twitch emote sync in the background");
|
||||||
|
CompletableFuture.runAsync(this::safeWarmGlobalEmotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void safeWarmGlobalEmotes() {
|
||||||
|
LOG.info("Initial global Twitch emote sync started");
|
||||||
|
try {
|
||||||
|
warmGlobalEmotes();
|
||||||
|
LOG.info(
|
||||||
|
"Initial global Twitch emote sync completed (cached {} emotes)",
|
||||||
|
globalEmotes.size()
|
||||||
|
);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
LOG.warn("Initial global Twitch emote sync failed", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void startInitialGlobalEmoteSync() {
|
||||||
|
ensureInitialGlobalSyncScheduled();
|
||||||
|
}
|
||||||
|
|
||||||
private List<CachedEmote> fetchChannelEmotes(String channelLogin) {
|
private List<CachedEmote> fetchChannelEmotes(String channelLogin) {
|
||||||
String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null);
|
String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null);
|
||||||
if (broadcasterId == null) {
|
if (broadcasterId == null) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package dev.kruhlmann.imgfloat.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package dev.kruhlmann.imgfloat.service;
|
package dev.kruhlmann.imgfloat.service;
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -17,32 +13,17 @@ import org.springframework.stereotype.Component;
|
|||||||
public class VersionService {
|
public class VersionService {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
|
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
|
||||||
private static final Pattern PACKAGE_VERSION_PATTERN = Pattern.compile("\"version\"\\s*:\\s*\"([^\"]+)\"");
|
|
||||||
|
|
||||||
private final String serverVersion;
|
private final String serverVersion;
|
||||||
private final String releaseVersion;
|
|
||||||
|
|
||||||
public VersionService() throws IOException {
|
public VersionService() {
|
||||||
this.serverVersion = resolveServerVersion();
|
this.serverVersion = resolveServerVersion();
|
||||||
this.releaseVersion = normalizeReleaseVersion(serverVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getVersion() {
|
public String getVersion() {
|
||||||
return serverVersion;
|
return serverVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getReleaseVersion() {
|
|
||||||
return releaseVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getReleaseTag() {
|
|
||||||
if (releaseVersion == null || releaseVersion.isBlank()) {
|
|
||||||
throw new IllegalStateException("Release version is not available");
|
|
||||||
}
|
|
||||||
String normalized = releaseVersion.startsWith("v") ? releaseVersion.substring(1) : releaseVersion;
|
|
||||||
return "v" + normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveServerVersion() {
|
private String resolveServerVersion() {
|
||||||
String pomVersion = getPomVersion();
|
String pomVersion = getPomVersion();
|
||||||
if (pomVersion != null && !pomVersion.isBlank()) {
|
if (pomVersion != null && !pomVersion.isBlank()) {
|
||||||
@@ -57,16 +38,6 @@ public class VersionService {
|
|||||||
throw new IllegalStateException("Release version is not available");
|
throw new IllegalStateException("Release version is not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeReleaseVersion(String baseVersion) throws IllegalStateException {
|
|
||||||
String normalized = baseVersion.trim();
|
|
||||||
normalized = normalized.replaceFirst("(?i)^v", "");
|
|
||||||
normalized = normalized.replaceFirst("-SNAPSHOT$", "");
|
|
||||||
if (normalized.isBlank()) {
|
|
||||||
throw new IllegalStateException("Invalid version: " + baseVersion);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getPomVersion() {
|
private String getPomVersion() {
|
||||||
try (
|
try (
|
||||||
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")
|
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.service.media;
|
package dev.kruhlmann.imgfloat.service.media;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ public record AssetContent(byte[] bytes, String mediaType) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "AssetContent{" + "bytes=" + Arrays.toString(bytes) + ", mediaType='" + mediaType + '\'' + '}';
|
return "AssetContent{" + "bytes=" + Arrays.toString(bytes) + ", mediaType='" + mediaType + '\'' + '}';
|
||||||
|
|||||||
@@ -42,11 +42,7 @@ public class MediaOptimizationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mediaType.startsWith("image/")) {
|
if (mediaType.startsWith("image/")) {
|
||||||
OptimizedAsset imageAsset = optimizeImage(bytes, mediaType);
|
return optimizeImage(bytes, mediaType);
|
||||||
if (imageAsset == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return imageAsset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType.startsWith("video/")) {
|
if (mediaType.startsWith("video/")) {
|
||||||
@@ -86,7 +82,7 @@ public class MediaOptimizationService {
|
|||||||
return "image/png".equalsIgnoreCase(mediaType) && ApngDetector.isApng(bytes);
|
return "image/png".equalsIgnoreCase(mediaType) && ApngDetector.isApng(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private OptimizedAsset optimizeApng(byte[] bytes, String mediaType) throws IOException {
|
private OptimizedAsset optimizeApng(byte[] bytes, String mediaType) {
|
||||||
return ffmpegService
|
return ffmpegService
|
||||||
.transcodeApngToGif(bytes)
|
.transcodeApngToGif(bytes)
|
||||||
.map(this::transcodeGifToVideo)
|
.map(this::transcodeGifToVideo)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.service.media;
|
package dev.kruhlmann.imgfloat.service.media;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ public record OptimizedAsset(byte[] bytes, String mediaType, int width, int heig
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE settings ADD COLUMN last_emote_sync_at TIMESTAMP;
|
||||||
@@ -26,6 +26,7 @@ export function createAdminConsole({
|
|||||||
const assets = new Map();
|
const assets = new Map();
|
||||||
const mediaCache = new Map();
|
const mediaCache = new Map();
|
||||||
const renderStates = new Map();
|
const renderStates = new Map();
|
||||||
|
const transformBaseline = new Map();
|
||||||
const animatedCache = new Map();
|
const animatedCache = new Map();
|
||||||
const audioControllers = new Map();
|
const audioControllers = new Map();
|
||||||
const pendingAudioUnlock = new Set();
|
const pendingAudioUnlock = new Set();
|
||||||
@@ -33,6 +34,9 @@ export function createAdminConsole({
|
|||||||
const previewCache = new Map();
|
const previewCache = new Map();
|
||||||
const previewImageCache = new Map();
|
const previewImageCache = new Map();
|
||||||
const pendingTransformSaves = new Map();
|
const pendingTransformSaves = new Map();
|
||||||
|
const livePreviewQueue = new Map();
|
||||||
|
const livePreviewLastSent = new Map();
|
||||||
|
let livePreviewFrameScheduled = false;
|
||||||
const HANDLE_SIZE = 10;
|
const HANDLE_SIZE = 10;
|
||||||
const ROTATE_HANDLE_OFFSET = 32;
|
const ROTATE_HANDLE_OFFSET = 32;
|
||||||
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
|
const VOLUME_SLIDER_MAX = SETTINGS.maxAssetVolumeFraction * 100;
|
||||||
@@ -103,6 +107,7 @@ export function createAdminConsole({
|
|||||||
let selectedAssetId = null;
|
let selectedAssetId = null;
|
||||||
let interactionState = null;
|
let interactionState = null;
|
||||||
let stompClient;
|
let stompClient;
|
||||||
|
let listNeedsRender = true;
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
applyCanvasSettings(canvasSettings);
|
applyCanvasSettings(canvasSettings);
|
||||||
@@ -179,6 +184,51 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestLiveTransform(asset) {
|
||||||
|
if (!asset?.id || isAudioAsset(asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = buildTransformPayload(asset);
|
||||||
|
if (!payload || !Object.keys(payload).length) {
|
||||||
|
livePreviewQueue.delete(asset.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(payload);
|
||||||
|
if (livePreviewLastSent.get(asset.id) === serialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
livePreviewQueue.set(asset.id, { payload, serialized });
|
||||||
|
requestLivePreviewFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelLiveTransform(assetId) {
|
||||||
|
livePreviewQueue.delete(assetId);
|
||||||
|
livePreviewLastSent.delete(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLivePreviewFrame() {
|
||||||
|
if (livePreviewFrameScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
livePreviewFrameScheduled = true;
|
||||||
|
requestAnimationFrame(sendQueuedLiveTransforms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendQueuedLiveTransforms() {
|
||||||
|
livePreviewFrameScheduled = false;
|
||||||
|
if (!livePreviewQueue.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stompClient || (typeof stompClient.connected === "boolean" && !stompClient.connected)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [assetId, { payload, serialized }] of livePreviewQueue.entries()) {
|
||||||
|
stompClient.send(`/app/channel/${broadcaster}/assets/${assetId}/preview`, {}, JSON.stringify(payload));
|
||||||
|
livePreviewLastSent.set(assetId, serialized);
|
||||||
|
}
|
||||||
|
livePreviewQueue.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function ensureLayerPosition(assetId, placement = "keep") {
|
function ensureLayerPosition(assetId, placement = "keep") {
|
||||||
ensureLayerPositionForState(layerState, assetId, placement);
|
ensureLayerPositionForState(layerState, assetId, placement);
|
||||||
}
|
}
|
||||||
@@ -506,7 +556,7 @@ export function createAdminConsole({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
schedulePersistTransform(asset);
|
schedulePersistTransform(asset);
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -520,6 +570,9 @@ export function createAdminConsole({
|
|||||||
handleEvent(body);
|
handleEvent(body);
|
||||||
});
|
});
|
||||||
fetchAssets();
|
fetchAssets();
|
||||||
|
if (livePreviewQueue.size) {
|
||||||
|
requestLivePreviewFrame();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.warn("WebSocket connection issue", error);
|
console.warn("WebSocket connection issue", error);
|
||||||
@@ -598,6 +651,7 @@ export function createAdminConsole({
|
|||||||
layerOrder = [];
|
layerOrder = [];
|
||||||
scriptLayerOrder = [];
|
scriptLayerOrder = [];
|
||||||
list.forEach((item) => storeAsset(item, { placement: "append" }));
|
list.forEach((item) => storeAsset(item, { placement: "append" }));
|
||||||
|
markListDirty();
|
||||||
drawAndList();
|
drawAndList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,6 +675,8 @@ export function createAdminConsole({
|
|||||||
if (!renderStates.has(asset.id)) {
|
if (!renderStates.has(asset.id)) {
|
||||||
renderStates.set(asset.id, { ...merged });
|
renderStates.set(asset.id, { ...merged });
|
||||||
}
|
}
|
||||||
|
updateTransformBaseline(merged);
|
||||||
|
markListDirty();
|
||||||
resolvePendingUploadByName(asset.name);
|
resolvePendingUploadByName(asset.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,22 +691,58 @@ export function createAdminConsole({
|
|||||||
renderStates.set(asset.id, state);
|
renderStates.set(asset.id, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTransformBaseline(asset) {
|
||||||
|
if (!asset?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const snapshot = {};
|
||||||
|
snapshot.audioVolume = Number.isFinite(asset.audioVolume) ? asset.audioVolume : undefined;
|
||||||
|
if (isAudioAsset(asset)) {
|
||||||
|
snapshot.audioLoop = asset.audioLoop;
|
||||||
|
snapshot.audioDelayMillis = Number.isFinite(asset.audioDelayMillis)
|
||||||
|
? asset.audioDelayMillis
|
||||||
|
: undefined;
|
||||||
|
snapshot.audioSpeed = Number.isFinite(asset.audioSpeed) ? asset.audioSpeed : undefined;
|
||||||
|
snapshot.audioPitch = Number.isFinite(asset.audioPitch) ? asset.audioPitch : undefined;
|
||||||
|
} else {
|
||||||
|
snapshot.x = Number.isFinite(asset.x) ? asset.x : undefined;
|
||||||
|
snapshot.y = Number.isFinite(asset.y) ? asset.y : undefined;
|
||||||
|
snapshot.width = Number.isFinite(asset.width) ? asset.width : undefined;
|
||||||
|
snapshot.height = Number.isFinite(asset.height) ? asset.height : undefined;
|
||||||
|
snapshot.rotation = Number.isFinite(asset.rotation) ? asset.rotation : undefined;
|
||||||
|
snapshot.speed = Number.isFinite(asset.speed) ? asset.speed : undefined;
|
||||||
|
snapshot.muted = asset.muted;
|
||||||
|
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
|
||||||
|
if (Number.isFinite(order)) {
|
||||||
|
snapshot.order = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transformBaseline.set(asset.id, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(event) {
|
function handleEvent(event) {
|
||||||
if (event.type === "CANVAS" && event.payload) {
|
if (event.type === "CANVAS" && event.payload) {
|
||||||
applyCanvasSettings(event.payload);
|
applyCanvasSettings(event.payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
|
applyPreviewPatch(assetId, event.patch);
|
||||||
|
drawAndList(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === "DELETED") {
|
if (event.type === "DELETED") {
|
||||||
assets.delete(assetId);
|
assets.delete(assetId);
|
||||||
layerOrder = layerOrder.filter((id) => id !== assetId);
|
layerOrder = layerOrder.filter((id) => id !== assetId);
|
||||||
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
|
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
|
||||||
clearMedia(assetId);
|
clearMedia(assetId);
|
||||||
renderStates.delete(assetId);
|
renderStates.delete(assetId);
|
||||||
|
transformBaseline.delete(assetId);
|
||||||
loopPlaybackState.delete(assetId);
|
loopPlaybackState.delete(assetId);
|
||||||
cancelPendingTransform(assetId);
|
cancelPendingTransform(assetId);
|
||||||
|
cancelLiveTransform(assetId);
|
||||||
if (selectedAssetId === assetId) {
|
if (selectedAssetId === assetId) {
|
||||||
selectedAssetId = null;
|
setSelectedAssetId(null);
|
||||||
}
|
}
|
||||||
} else if (event.patch) {
|
} else if (event.patch) {
|
||||||
applyPatch(assetId, event.patch);
|
applyPatch(assetId, event.patch);
|
||||||
@@ -666,7 +758,30 @@ export function createAdminConsole({
|
|||||||
loopPlaybackState.delete(event.payload.id);
|
loopPlaybackState.delete(event.payload.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drawAndList();
|
drawAndList(shouldRenderAssetList(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderAssetList(event) {
|
||||||
|
if (!event) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.type === "PREVIEW") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { type, payload, patch } = event;
|
||||||
|
if (type === "DELETED" || type === "VISIBILITY") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (payload) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (patch) {
|
||||||
|
if (patch.hidden != null || patch.order != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPatch(assetId, patch) {
|
function applyPatch(assetId, patch) {
|
||||||
@@ -706,9 +821,66 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAndList() {
|
function applyPreviewPatch(assetId, patch) {
|
||||||
|
if (!assetId || !patch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = assets.get(assetId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
const isAudio = isAudioAsset(merged);
|
||||||
|
const isScript = isCodeAsset(merged);
|
||||||
|
if (patch.hidden) {
|
||||||
|
clearMedia(assetId);
|
||||||
|
loopPlaybackState.delete(assetId);
|
||||||
|
}
|
||||||
|
const targetOrder = Number.isFinite(patch.order) ? patch.order : null;
|
||||||
|
if (!isAudio && Number.isFinite(targetOrder)) {
|
||||||
|
if (isScript) {
|
||||||
|
const currentOrder = getScriptLayerOrder().filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
scriptLayerOrder = currentOrder;
|
||||||
|
} else {
|
||||||
|
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
layerOrder = currentOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assets.set(assetId, merged);
|
||||||
|
if (!isAudio) {
|
||||||
|
updateRenderState(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markListDirty() {
|
||||||
|
listNeedsRender = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markListDirty() {
|
||||||
|
listNeedsRender = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAndList(renderList = false) {
|
||||||
requestDraw();
|
requestDraw();
|
||||||
renderAssetList();
|
if (renderList || listNeedsRender) {
|
||||||
|
renderAssetList();
|
||||||
|
listNeedsRender = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedAssetId(id) {
|
||||||
|
if (selectedAssetId === id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
selectedAssetId = id;
|
||||||
|
markListDirty();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDraw() {
|
function requestDraw() {
|
||||||
@@ -1003,6 +1175,7 @@ export function createAdminConsole({
|
|||||||
asset.width = nextWidth;
|
asset.width = nextWidth;
|
||||||
asset.height = nextHeight;
|
asset.height = nextHeight;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1470,7 +1643,7 @@ export function createAdminConsole({
|
|||||||
toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset";
|
toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset";
|
||||||
toggleBtn.addEventListener("click", (e) => {
|
toggleBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectedAssetId = asset.id;
|
setSelectedAssetId(asset.id);
|
||||||
updateVisibility(asset, !asset.hidden);
|
updateVisibility(asset, !asset.hidden);
|
||||||
});
|
});
|
||||||
actions.appendChild(toggleBtn);
|
actions.appendChild(toggleBtn);
|
||||||
@@ -1481,7 +1654,7 @@ export function createAdminConsole({
|
|||||||
row.appendChild(actions);
|
row.appendChild(actions);
|
||||||
|
|
||||||
li.addEventListener("click", () => {
|
li.addEventListener("click", () => {
|
||||||
selectedAssetId = asset.id;
|
setSelectedAssetId(asset.id);
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
drawAndList();
|
drawAndList();
|
||||||
});
|
});
|
||||||
@@ -1504,7 +1677,7 @@ export function createAdminConsole({
|
|||||||
const hasPending = pendingUploads.length > 0;
|
const hasPending = pendingUploads.length > 0;
|
||||||
|
|
||||||
if (!hasAssets && !hasPending) {
|
if (!hasAssets && !hasPending) {
|
||||||
selectedAssetId = null;
|
setSelectedAssetId(null);
|
||||||
if (assetInspector) {
|
if (assetInspector) {
|
||||||
assetInspector.classList.add("hidden");
|
assetInspector.classList.add("hidden");
|
||||||
}
|
}
|
||||||
@@ -2055,7 +2228,7 @@ export function createAdminConsole({
|
|||||||
if (media) {
|
if (media) {
|
||||||
applyMediaSettings(media, asset);
|
applyMediaSettings(media, asset);
|
||||||
}
|
}
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVolumeFromInput() {
|
function updateVolumeFromInput() {
|
||||||
@@ -2074,7 +2247,7 @@ export function createAdminConsole({
|
|||||||
applyAudioSettings(controller, asset);
|
applyAudioSettings(controller, asset);
|
||||||
}
|
}
|
||||||
schedulePersistTransform(asset);
|
schedulePersistTransform(asset);
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAudioSettingsFromInputs() {
|
function updateAudioSettingsFromInputs() {
|
||||||
@@ -2104,7 +2277,7 @@ export function createAdminConsole({
|
|||||||
const controller = ensureAudioController(asset);
|
const controller = ensureAudioController(asset);
|
||||||
applyAudioSettings(controller, asset);
|
applyAudioSettings(controller, asset);
|
||||||
schedulePersistTransform(asset);
|
schedulePersistTransform(asset);
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nudgeRotation(delta) {
|
function nudgeRotation(delta) {
|
||||||
@@ -2114,7 +2287,7 @@ export function createAdminConsole({
|
|||||||
asset.rotation = next;
|
asset.rotation = next;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
persistTransform(asset);
|
persistTransform(asset);
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recenterSelectedAsset() {
|
function recenterSelectedAsset() {
|
||||||
@@ -2126,7 +2299,7 @@ export function createAdminConsole({
|
|||||||
asset.y = centerY;
|
asset.y = centerY;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
persistTransform(asset);
|
persistTransform(asset);
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLayeredAssets(asset) {
|
function getLayeredAssets(asset) {
|
||||||
@@ -2195,22 +2368,94 @@ export function createAdminConsole({
|
|||||||
globalThis.sendToBack = sendToBack;
|
globalThis.sendToBack = sendToBack;
|
||||||
|
|
||||||
function applyLayerOrder(ordered) {
|
function applyLayerOrder(ordered) {
|
||||||
|
if (!ordered || !ordered.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousOrder = [...layerOrder];
|
||||||
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
|
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
|
||||||
layerOrder = newOrder;
|
layerOrder = newOrder;
|
||||||
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
|
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
|
||||||
changed.forEach((item) => updateRenderState(item));
|
changed.forEach((item) => updateRenderState(item));
|
||||||
changed.forEach((item) => schedulePersistTransform(item, true));
|
const orderUpdates = buildOrderUpdates(ordered, previousOrder);
|
||||||
|
sendOrderUpdates(orderUpdates, () => {
|
||||||
|
layerOrder = previousOrder;
|
||||||
|
drawAndList();
|
||||||
|
});
|
||||||
|
markListDirty();
|
||||||
drawAndList();
|
drawAndList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyScriptLayerOrder(ordered) {
|
function applyScriptLayerOrder(ordered) {
|
||||||
|
if (!ordered || !ordered.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousOrder = [...scriptLayerOrder];
|
||||||
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
|
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
|
||||||
scriptLayerOrder = newOrder;
|
scriptLayerOrder = newOrder;
|
||||||
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
|
const orderUpdates = buildOrderUpdates(ordered, previousOrder);
|
||||||
changed.forEach((item) => schedulePersistTransform(item, true));
|
sendOrderUpdates(orderUpdates, () => {
|
||||||
|
scriptLayerOrder = previousOrder;
|
||||||
|
drawAndList();
|
||||||
|
});
|
||||||
|
markListDirty();
|
||||||
drawAndList();
|
drawAndList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOrderUpdates(ordered, previousOrderIds) {
|
||||||
|
if (!ordered || !ordered.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const previousLength = previousOrderIds.length || ordered.length;
|
||||||
|
const previousOrderMap = new Map();
|
||||||
|
previousOrderIds.forEach((id, index) => {
|
||||||
|
previousOrderMap.set(id, previousLength - index);
|
||||||
|
});
|
||||||
|
const updates = [];
|
||||||
|
const newLength = ordered.length;
|
||||||
|
ordered.forEach((asset, index) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newOrderValue = newLength - index;
|
||||||
|
const previousValue = previousOrderMap.get(asset.id);
|
||||||
|
if (previousValue !== newOrderValue) {
|
||||||
|
updates.push({ assetId: asset.id, order: newOrderValue });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOrderUpdates(updates, onError) {
|
||||||
|
if (!updates || !updates.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/channels/${broadcaster}/assets/order`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ updates }),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return extractErrorMessage(response, "Unable to reorder assets right now.").then((message) => {
|
||||||
|
throw new Error(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
const assetDetails = updates
|
||||||
|
.map((update) => {
|
||||||
|
const asset = assets.get(update.assetId);
|
||||||
|
return `${asset?.name ?? "unknown"} (${update.assetId})`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
console.warn("Asset reorder failed for", assetDetails, error?.message || error);
|
||||||
|
showToast(error?.message || "Unable to reorder assets right now.", "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getAssetAspectRatio(asset) {
|
function getAssetAspectRatio(asset) {
|
||||||
const media = ensureMedia(asset);
|
const media = ensureMedia(asset);
|
||||||
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
|
if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) {
|
||||||
@@ -2318,12 +2563,15 @@ export function createAdminConsole({
|
|||||||
clearMedia(asset.id);
|
clearMedia(asset.id);
|
||||||
assets.delete(asset.id);
|
assets.delete(asset.id);
|
||||||
renderStates.delete(asset.id);
|
renderStates.delete(asset.id);
|
||||||
|
transformBaseline.delete(asset.id);
|
||||||
layerOrder = layerOrder.filter((id) => id !== asset.id);
|
layerOrder = layerOrder.filter((id) => id !== asset.id);
|
||||||
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
|
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
|
||||||
cancelPendingTransform(asset.id);
|
cancelPendingTransform(asset.id);
|
||||||
|
cancelLiveTransform(asset.id);
|
||||||
if (selectedAssetId === asset.id) {
|
if (selectedAssetId === asset.id) {
|
||||||
selectedAssetId = null;
|
setSelectedAssetId(null);
|
||||||
}
|
}
|
||||||
|
markListDirty();
|
||||||
drawAndList();
|
drawAndList();
|
||||||
showToast("Asset deleted.", "info");
|
showToast("Asset deleted.", "info");
|
||||||
})
|
})
|
||||||
@@ -2452,29 +2700,14 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistTransform(asset, silent = false) {
|
function persistTransform(asset, silent = false) {
|
||||||
|
if (!asset || !asset.id) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
cancelPendingTransform(asset.id);
|
cancelPendingTransform(asset.id);
|
||||||
const payload = {
|
cancelLiveTransform(asset.id);
|
||||||
audioLoop: asset.audioLoop,
|
const payload = buildTransformPayload(asset);
|
||||||
audioDelayMillis: asset.audioDelayMillis,
|
if (!Object.keys(payload).length) {
|
||||||
audioSpeed: asset.audioSpeed,
|
return Promise.resolve();
|
||||||
audioPitch: asset.audioPitch,
|
|
||||||
audioVolume: asset.audioVolume,
|
|
||||||
};
|
|
||||||
if (isCodeAsset(asset)) {
|
|
||||||
const order = getScriptLayerValue(asset.id);
|
|
||||||
payload.order = order;
|
|
||||||
} else if (!isAudioAsset(asset)) {
|
|
||||||
const order = getLayerValue(asset.id);
|
|
||||||
payload.x = asset.x;
|
|
||||||
payload.y = asset.y;
|
|
||||||
payload.width = asset.width;
|
|
||||||
payload.height = asset.height;
|
|
||||||
payload.rotation = asset.rotation;
|
|
||||||
payload.speed = asset.speed;
|
|
||||||
payload.order = order;
|
|
||||||
if (isVideoAsset(asset)) {
|
|
||||||
payload.muted = asset.muted;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
|
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -2493,7 +2726,7 @@ export function createAdminConsole({
|
|||||||
storeAsset(updated);
|
storeAsset(updated);
|
||||||
updateRenderState(updated);
|
updateRenderState(updated);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
drawAndList();
|
drawAndList(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -2503,6 +2736,57 @@ export function createAdminConsole({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTransformPayload(asset) {
|
||||||
|
if (!asset?.id) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const baseline = transformBaseline.get(asset.id) || {};
|
||||||
|
const payload = {};
|
||||||
|
const addNumber = (key, value) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = baseline[key];
|
||||||
|
if (Number.isFinite(previous) && previous === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload[key] = value;
|
||||||
|
};
|
||||||
|
const addBoolean = (key, value) => {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (baseline[key] === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
addBoolean("audioLoop", asset.audioLoop);
|
||||||
|
addNumber("audioDelayMillis", asset.audioDelayMillis);
|
||||||
|
addNumber("audioSpeed", asset.audioSpeed);
|
||||||
|
addNumber("audioPitch", asset.audioPitch);
|
||||||
|
addNumber("audioVolume", asset.audioVolume);
|
||||||
|
|
||||||
|
if (!isAudioAsset(asset)) {
|
||||||
|
addNumber("x", asset.x);
|
||||||
|
addNumber("y", asset.y);
|
||||||
|
addNumber("width", asset.width);
|
||||||
|
addNumber("height", asset.height);
|
||||||
|
addNumber("rotation", asset.rotation);
|
||||||
|
addNumber("speed", asset.speed);
|
||||||
|
if (isVideoAsset(asset)) {
|
||||||
|
addBoolean("muted", asset.muted);
|
||||||
|
}
|
||||||
|
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
|
||||||
|
if (Number.isFinite(order) && baseline.order !== order) {
|
||||||
|
payload.order = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
canvas.addEventListener("mousedown", (event) => {
|
canvas.addEventListener("mousedown", (event) => {
|
||||||
const point = getCanvasPoint(event);
|
const point = getCanvasPoint(event);
|
||||||
const current = getSelectedAsset();
|
const current = getSelectedAsset();
|
||||||
@@ -2530,7 +2814,7 @@ export function createAdminConsole({
|
|||||||
|
|
||||||
const hit = findAssetAtPoint(point.x, point.y);
|
const hit = findAssetAtPoint(point.x, point.y);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
selectedAssetId = hit.id;
|
setSelectedAssetId(hit.id);
|
||||||
updateRenderState(hit);
|
updateRenderState(hit);
|
||||||
interactionState = {
|
interactionState = {
|
||||||
mode: "move",
|
mode: "move",
|
||||||
@@ -2540,7 +2824,7 @@ export function createAdminConsole({
|
|||||||
};
|
};
|
||||||
canvas.style.cursor = "grabbing";
|
canvas.style.cursor = "grabbing";
|
||||||
} else {
|
} else {
|
||||||
selectedAssetId = null;
|
setSelectedAssetId(null);
|
||||||
interactionState = null;
|
interactionState = null;
|
||||||
canvas.style.cursor = "default";
|
canvas.style.cursor = "default";
|
||||||
}
|
}
|
||||||
@@ -2565,6 +2849,7 @@ export function createAdminConsole({
|
|||||||
asset.y = point.y - interactionState.offsetY;
|
asset.y = point.y - interactionState.offsetY;
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
canvas.style.cursor = "grabbing";
|
canvas.style.cursor = "grabbing";
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
} else if (interactionState.mode === "resize") {
|
} else if (interactionState.mode === "resize") {
|
||||||
resizeFromHandle(interactionState, point);
|
resizeFromHandle(interactionState, point);
|
||||||
@@ -2574,6 +2859,7 @@ export function createAdminConsole({
|
|||||||
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle);
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
canvas.style.cursor = "grabbing";
|
canvas.style.cursor = "grabbing";
|
||||||
|
requestLiveTransform(asset);
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2587,6 +2873,7 @@ export function createAdminConsole({
|
|||||||
canvas.style.cursor = "default";
|
canvas.style.cursor = "default";
|
||||||
drawAndList();
|
drawAndList();
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
cancelLiveTransform(asset.id);
|
||||||
persistTransform(asset);
|
persistTransform(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2606,7 +2893,7 @@ export function createAdminConsole({
|
|||||||
}
|
}
|
||||||
storeAsset(asset);
|
storeAsset(asset);
|
||||||
updateRenderState(asset);
|
updateRenderState(asset);
|
||||||
selectedAssetId = asset.id;
|
setSelectedAssetId(asset.id);
|
||||||
updateSelectedAssetControls(asset);
|
updateSelectedAssetControls(asset);
|
||||||
drawAndList();
|
drawAndList();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ export class BroadcastRenderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
|
||||||
|
if (event.type === "PREVIEW" && event.patch) {
|
||||||
|
this.applyPreviewPatch(assetId, event.patch);
|
||||||
|
this.draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.type === "VISIBILITY") {
|
if (event.type === "VISIBILITY") {
|
||||||
this.handleVisibilityEvent(event);
|
this.handleVisibilityEvent(event);
|
||||||
return;
|
return;
|
||||||
@@ -276,15 +281,12 @@ export class BroadcastRenderer {
|
|||||||
if (!assetId || !patch) {
|
if (!assetId || !patch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sanitizedPatch = Object.fromEntries(
|
const sanitizedPatch = this.sanitizePatch(patch);
|
||||||
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
|
|
||||||
);
|
|
||||||
const existing = this.state.assets.get(assetId);
|
const existing = this.state.assets.get(assetId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
||||||
console.log(merged);
|
|
||||||
const isVisual = isVisualAsset(merged);
|
const isVisual = isVisualAsset(merged);
|
||||||
const isScript = isCodeAsset(merged);
|
const isScript = isCodeAsset(merged);
|
||||||
if (sanitizedPatch.hidden) {
|
if (sanitizedPatch.hidden) {
|
||||||
@@ -316,6 +318,54 @@ export class BroadcastRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPreviewPatch(assetId, patch) {
|
||||||
|
if (!assetId || !patch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sanitizedPatch = this.sanitizePatch(patch);
|
||||||
|
if (!Object.keys(sanitizedPatch).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.state.assets.get(assetId);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = this.normalizePayload({ ...existing, ...sanitizedPatch });
|
||||||
|
if (sanitizedPatch.hidden) {
|
||||||
|
this.hideAssetWithTransition(merged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isVisual = isVisualAsset(merged);
|
||||||
|
const isScript = isCodeAsset(merged);
|
||||||
|
const targetOrder = Number.isFinite(sanitizedPatch.order) ? sanitizedPatch.order : null;
|
||||||
|
if (Number.isFinite(targetOrder)) {
|
||||||
|
if (isScript) {
|
||||||
|
const currentOrder = getScriptLayerOrder(this.state).filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
this.state.scriptLayerOrder = currentOrder;
|
||||||
|
this.applyScriptCanvasOrder();
|
||||||
|
} else if (isVisual) {
|
||||||
|
const currentOrder = getLayerOrder(this.state).filter((id) => id !== assetId);
|
||||||
|
const totalCount = currentOrder.length + 1;
|
||||||
|
const insertIndex = Math.max(0, Math.min(currentOrder.length, totalCount - Math.round(targetOrder)));
|
||||||
|
currentOrder.splice(insertIndex, 0, assetId);
|
||||||
|
this.state.layerOrder = currentOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.assets.set(assetId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizePatch(patch) {
|
||||||
|
if (!patch) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
if (this.frameScheduled) {
|
if (this.frameScheduled) {
|
||||||
this.pendingDraw = true;
|
this.pendingDraw = true;
|
||||||
|
|||||||
@@ -10,24 +10,18 @@ export function getVisibilityState(state, asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function smoothState(state, asset) {
|
export function smoothState(state, asset) {
|
||||||
const previous = state.renderStates.get(asset.id) || { ...asset };
|
const previous = state.renderStates.get(asset.id) || {};
|
||||||
const factor = 0.15;
|
|
||||||
const next = {
|
const next = {
|
||||||
x: lerp(previous.x, asset.x, factor),
|
x: Number.isFinite(asset.x) ? asset.x : previous.x ?? 0,
|
||||||
y: lerp(previous.y, asset.y, factor),
|
y: Number.isFinite(asset.y) ? asset.y : previous.y ?? 0,
|
||||||
width: lerp(previous.width, asset.width, factor),
|
width: Number.isFinite(asset.width) ? asset.width : previous.width ?? 0,
|
||||||
height: lerp(previous.height, asset.height, factor),
|
height: Number.isFinite(asset.height) ? asset.height : previous.height ?? 0,
|
||||||
rotation: smoothAngle(previous.rotation, asset.rotation, factor),
|
rotation: Number.isFinite(asset.rotation) ? asset.rotation : previous.rotation ?? 0,
|
||||||
};
|
};
|
||||||
state.renderStates.set(asset.id, next);
|
state.renderStates.set(asset.id, next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function smoothAngle(current, target, factor) {
|
|
||||||
const delta = ((target - current + 180) % 360) - 180;
|
|
||||||
return current + delta * factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a, b, t) {
|
function lerp(a, b, t) {
|
||||||
return a + (b - a) * t;
|
return a + (b - a) * t;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="property-row">
|
<div class="property-row">
|
||||||
<span class="property-label">Maintain AR</span>
|
<span class="property-label">Maintain aspect ratio</span>
|
||||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||||
<input id="maintain-aspect" type="checkbox" checked />
|
<input id="maintain-aspect" type="checkbox" checked />
|
||||||
<span class="toggle-track" aria-hidden="true">
|
<span class="toggle-track" aria-hidden="true">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import dev.kruhlmann.imgfloat.model.Asset;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AudioAsset;
|
|
||||||
import dev.kruhlmann.imgfloat.model.Channel;
|
|
||||||
import dev.kruhlmann.imgfloat.model.ScriptAsset;
|
|
||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
|
||||||
import dev.kruhlmann.imgfloat.model.AssetType;
|
import dev.kruhlmann.imgfloat.model.AssetType;
|
||||||
import dev.kruhlmann.imgfloat.model.Settings;
|
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
|
||||||
|
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
|
||||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||||
@@ -64,12 +64,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
private VisualAssetRepository visualAssetRepository;
|
private VisualAssetRepository visualAssetRepository;
|
||||||
private AudioAssetRepository audioAssetRepository;
|
private AudioAssetRepository audioAssetRepository;
|
||||||
private ScriptAssetRepository scriptAssetRepository;
|
private ScriptAssetRepository scriptAssetRepository;
|
||||||
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
|
||||||
private ScriptAssetFileRepository scriptAssetFileRepository;
|
private ScriptAssetFileRepository scriptAssetFileRepository;
|
||||||
private MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
|
|
||||||
private SettingsService settingsService;
|
|
||||||
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
|
||||||
private AuditLogService auditLogService;
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() throws Exception {
|
void setup() throws Exception {
|
||||||
@@ -79,13 +74,13 @@ class ChannelDirectoryServiceTest {
|
|||||||
visualAssetRepository = mock(VisualAssetRepository.class);
|
visualAssetRepository = mock(VisualAssetRepository.class);
|
||||||
audioAssetRepository = mock(AudioAssetRepository.class);
|
audioAssetRepository = mock(AudioAssetRepository.class);
|
||||||
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
||||||
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
||||||
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
|
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
|
||||||
marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
|
MarketplaceScriptHeartRepository marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
|
||||||
auditLogService = mock(AuditLogService.class);
|
AuditLogService auditLogService = mock(AuditLogService.class);
|
||||||
when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of());
|
when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of());
|
||||||
when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of());
|
when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of());
|
||||||
settingsService = mock(SettingsService.class);
|
SettingsService settingsService = mock(SettingsService.class);
|
||||||
when(settingsService.get()).thenReturn(Settings.defaults());
|
when(settingsService.get()).thenReturn(Settings.defaults());
|
||||||
setupInMemoryPersistence();
|
setupInMemoryPersistence();
|
||||||
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
|
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
|
||||||
@@ -112,24 +107,24 @@ class ChannelDirectoryServiceTest {
|
|||||||
Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');");
|
Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');");
|
||||||
Files.write(scriptRoot.resolve("logo.png"), samplePng());
|
Files.write(scriptRoot.resolve("logo.png"), samplePng());
|
||||||
Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng());
|
Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng());
|
||||||
marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
|
MarketplaceScriptSeedLoader marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
|
||||||
service = new ChannelDirectoryService(
|
service = new ChannelDirectoryService(
|
||||||
channelRepository,
|
channelRepository,
|
||||||
assetRepository,
|
assetRepository,
|
||||||
visualAssetRepository,
|
visualAssetRepository,
|
||||||
audioAssetRepository,
|
audioAssetRepository,
|
||||||
scriptAssetRepository,
|
scriptAssetRepository,
|
||||||
scriptAssetAttachmentRepository,
|
scriptAssetAttachmentRepository,
|
||||||
scriptAssetFileRepository,
|
scriptAssetFileRepository,
|
||||||
marketplaceScriptHeartRepository,
|
marketplaceScriptHeartRepository,
|
||||||
messagingTemplate,
|
messagingTemplate,
|
||||||
assetStorageService,
|
assetStorageService,
|
||||||
mediaDetectionService,
|
mediaDetectionService,
|
||||||
mediaOptimizationService,
|
mediaOptimizationService,
|
||||||
settingsService,
|
settingsService,
|
||||||
uploadLimitBytes,
|
uploadLimitBytes,
|
||||||
marketplaceScriptSeedLoader,
|
marketplaceScriptSeedLoader,
|
||||||
auditLogService
|
auditLogService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,9 +190,14 @@ class ChannelDirectoryServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void appliesBoundaryValues() throws Exception {
|
void appliesBoundaryValues() {
|
||||||
String channel = "caster";
|
String channel = "caster";
|
||||||
String id = createSampleAsset(channel);
|
String id = null;
|
||||||
|
try {
|
||||||
|
id = createSampleAsset(channel);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
TransformRequest transform = validTransform();
|
TransformRequest transform = validTransform();
|
||||||
transform.setSpeed(0.1);
|
transform.setSpeed(0.1);
|
||||||
@@ -215,7 +215,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
void includesDefaultMarketplaceScript() {
|
void includesDefaultMarketplaceScript() {
|
||||||
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
|
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
|
||||||
|
|
||||||
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null);
|
List<dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null);
|
||||||
|
|
||||||
assertThat(entries)
|
assertThat(entries)
|
||||||
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
|
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
|
||||||
@@ -229,7 +229,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
request.setAllowedDomains(List.of("example.com"));
|
request.setAllowedDomains(List.of("example.com"));
|
||||||
AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow();
|
AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow();
|
||||||
scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id()));
|
scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id()));
|
||||||
scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.ScriptAssetFile("caster", AssetType.SCRIPT) {
|
scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile("caster", AssetType.SCRIPT) {
|
||||||
{
|
{
|
||||||
setId(created.id());
|
setId(created.id());
|
||||||
}
|
}
|
||||||
@@ -271,10 +271,10 @@ class ChannelDirectoryServiceTest {
|
|||||||
private void setupInMemoryPersistence() {
|
private void setupInMemoryPersistence() {
|
||||||
Map<String, Channel> channels = new ConcurrentHashMap<>();
|
Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||||
Map<String, Asset> assets = new ConcurrentHashMap<>();
|
Map<String, Asset> assets = new ConcurrentHashMap<>();
|
||||||
Map<String, dev.kruhlmann.imgfloat.model.VisualAsset> visualAssets = new ConcurrentHashMap<>();
|
Map<String, dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset> visualAssets = new ConcurrentHashMap<>();
|
||||||
Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>();
|
Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>();
|
||||||
Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>();
|
Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>();
|
||||||
Map<String, dev.kruhlmann.imgfloat.model.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>();
|
Map<String, dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
|
when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||||
Optional.ofNullable(channels.get(invocation.getArgument(0)))
|
Optional.ofNullable(channels.get(invocation.getArgument(0)))
|
||||||
@@ -317,9 +317,9 @@ class ChannelDirectoryServiceTest {
|
|||||||
.when(assetRepository)
|
.when(assetRepository)
|
||||||
.delete(any(Asset.class));
|
.delete(any(Asset.class));
|
||||||
|
|
||||||
when(visualAssetRepository.save(any(dev.kruhlmann.imgfloat.model.VisualAsset.class))).thenAnswer(
|
when(visualAssetRepository.save(any(dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset.class))).thenAnswer(
|
||||||
(invocation) -> {
|
(invocation) -> {
|
||||||
dev.kruhlmann.imgfloat.model.VisualAsset visual = invocation.getArgument(0);
|
dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset visual = invocation.getArgument(0);
|
||||||
visualAssets.put(visual.getId(), visual);
|
visualAssets.put(visual.getId(), visual);
|
||||||
return visual;
|
return visual;
|
||||||
}
|
}
|
||||||
@@ -388,9 +388,9 @@ class ChannelDirectoryServiceTest {
|
|||||||
.when(scriptAssetRepository)
|
.when(scriptAssetRepository)
|
||||||
.deleteById(anyString());
|
.deleteById(anyString());
|
||||||
|
|
||||||
when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.ScriptAssetFile.class))).thenAnswer(
|
when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile.class))).thenAnswer(
|
||||||
(invocation) -> {
|
(invocation) -> {
|
||||||
dev.kruhlmann.imgfloat.model.ScriptAssetFile file = invocation.getArgument(0);
|
dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile file = invocation.getArgument(0);
|
||||||
if (file.getId() == null) {
|
if (file.getId() == null) {
|
||||||
file.setId(java.util.UUID.randomUUID().toString());
|
file.setId(java.util.UUID.randomUUID().toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,21 +43,25 @@ class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
|
|||||||
.state("state")
|
.state("state")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
MultiValueMap<String, String> body = getStringStringMultiValueMap(authorizationRequest, authorizationResponse, registration);
|
||||||
|
|
||||||
|
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
|
||||||
|
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MultiValueMap<String, String> getStringStringMultiValueMap(OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationResponse authorizationResponse, ClientRegistration registration) {
|
||||||
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
|
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
|
||||||
authorizationRequest,
|
authorizationRequest,
|
||||||
authorizationResponse
|
authorizationResponse
|
||||||
);
|
);
|
||||||
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
|
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
|
||||||
registration,
|
registration,
|
||||||
exchange
|
exchange
|
||||||
);
|
);
|
||||||
|
|
||||||
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
|
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
|
||||||
RequestEntity<?> requestEntity = converter.convert(grantRequest);
|
RequestEntity<?> requestEntity = converter.convert(grantRequest);
|
||||||
|
|
||||||
MultiValueMap<String, String> body = (MultiValueMap<String, String>) requestEntity.getBody();
|
return (MultiValueMap<String, String>) requestEntity.getBody();
|
||||||
|
|
||||||
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
|
|
||||||
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
|||||||
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
|
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void fallsBackToSyntheticErrorWhenErrorBodyIsMissing() throws Exception {
|
void fallsBackToSyntheticErrorWhenErrorBodyIsMissing() {
|
||||||
MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST);
|
MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST);
|
||||||
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user