Skip to content
This repository was archived by the owner on May 21, 2026. It is now read-only.

Commit ce3dddd

Browse files
Add Spring Batch support and batch endpoints
Introduce Spring Batch-based media update jobs and runtime control. Added chunk-oriented batch components (Movie/Serie JsonReader, Processor, ItemWriter), JobsConfig, and an async TaskExecutor JobLauncher (BatchLauncherConfig). Expose a simple admin BatchController (/admin/batch) to run/stop/abandon jobs at runtime. Add Flyway migrations for Spring Batch schema (V13) and a new users.user_role column (V14). Update security and user model to persist and normalize roles (userRole column, JwtConfig and UserDetailsServiceImpl changes include ID_ role handling). Turn off auto-initialized batch jobs by default in application.properties and remove Spring Shell (dependency and UpdateCommands) in favor of batch jobs; legacy threaded update methods in MovieControllerImpl and SerieControllerImpl are deprecated/adjusted for removal. Minor fixes: remove a redundant Content-Type header in DataJumpUtils and tidy imports/README with batch usage documentation.
1 parent 45f01b9 commit ce3dddd

22 files changed

Lines changed: 1034 additions & 79 deletions

README.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,48 @@
77
</div>
88

99
## Usage
10-
- See the GraphQL guide: [GraphQL Guide](graphql_guide.md);
11-
- See the license: [Licenses](LICENSE.txt)
12-
10+
See the GraphQL guide: [GraphQL Guide](graphql_guide.md)
11+
See the license: [Licenses](LICENSE.txt)
12+
13+
**Other Docs**: quick links to other markdown files in the project
14+
- `INDEX.md`: Project index and quick references - [INDEX.md](INDEX.md)
15+
- `MONITORING.md`: Monitoring & observability notes - [MONITORING.md](MONITORING.md)
16+
- `docker/ARCHITECTURE.md`: Docker / architecture notes - [docker/ARCHITECTURE.md](docker/ARCHITECTURE.md)
17+
- `docker/DEPLOYMENT.md`: Docker deployment guide - [docker/DEPLOYMENT.md](docker/DEPLOYMENT.md)
18+
- `docker/QUICK_REFERENCE.md`: Docker quick reference - [docker/QUICK_REFERENCE.md](docker/QUICK_REFERENCE.md)
19+
- `docker/SETUP.md`: Docker setup guide - [docker/SETUP.md](docker/SETUP.md)
20+
- `docker/TESTING.md`: Docker testing notes - [docker/TESTING.md](docker/TESTING.md)
21+
- `graphql_guide.md`: GraphQL guide - [graphql_guide.md](graphql_guide.md)
1322
## Setup
1423

1524
### Run & Build Guide (JVM and Native)
1625

26+
27+
## Batch Endpoint
28+
29+
This project exposes a simple administrative Batch API for running and controlling Spring Batch jobs at runtime. The controller is available at the path `/admin/batch` and provides three endpoints:
30+
31+
- **Run job**: `GET /admin/batch/run/{jobName}`
32+
- Description: Triggers a job bean (by Spring bean name) found in the application context.
33+
- Notes: Adds a timestamp parameter to ensure a new JobParameters set for each run.
34+
- Authorization: requires `admin` role (method guarded by `@PreAuthorize("hasRole('admin')")`).
35+
- Response: HTTP 200 with `Job ID: <id>` when submitted, or HTTP 500 with error message on failure.
36+
37+
- **Stop job execution**: `GET /admin/batch/stop/{executionId}`
38+
- Description: Requests to stop a running job execution via `JobOperator.stop(executionId)`.
39+
- Authorization: requires `admin` role.
40+
- Response: HTTP 200 with `stop requested: true|false` or HTTP 500 on error.
41+
42+
- **Abandon job execution**: `GET /admin/batch/abandon/{executionId}`
43+
- Description: Marks a failed/stopped execution as abandoned using `JobOperator.abandon(executionId)`.
44+
- Authorization: requires `admin` role.
45+
- Response: HTTP 200 with `abandoned` or HTTP 500 on error.
46+
47+
Implementation notes:
48+
- The controller locates jobs by bean name from the `ApplicationContext` and uses an asynchronous `JobLauncher` (qualified as `asyncJobLauncher`) to start the job, returning the `JobExecution` id.
49+
- For stop/abandon actions the controller calls the provided `JobOperator` to control executions.
50+
- Make sure the `admin` role is available and that requests are authenticated; adjust security configuration if necessary for your environment.
51+
1752
#### 1. Project Overview
1853
Java 21 / Spring Boot 3 application with optional native image build using GraalVM.
1954

build.gradle

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ dependencies {
8989
implementation 'org.springframework.boot:spring-boot-starter-web'
9090
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
9191

92+
// Spring Batch for job management (start/stop/abandon)
93+
implementation 'org.springframework.boot:spring-boot-starter-batch'
94+
9295
implementation platform('com.squareup.okhttp3:okhttp-bom:4.12.0')
9396
implementation 'com.squareup.okhttp3:okhttp'
9497
implementation 'org.springframework.boot:spring-boot-starter-validation'
@@ -119,9 +122,6 @@ dependencies {
119122
testImplementation 'org.springframework.security:spring-security-test'
120123
testRuntimeOnly 'com.h2database:h2'
121124
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
122-
123-
// Spring Shell for interactive CLI commands
124-
implementation 'org.springframework.shell:spring-shell-starter:3.0.0'
125125
}
126126

127127
tasks.withType(Test) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.espacogeek.geek.batch;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import jakarta.annotation.PostConstruct;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Qualifier;
10+
import org.springframework.stereotype.Component;
11+
import org.springframework.batch.item.ItemWriter;
12+
13+
import com.espacogeek.geek.data.api.MediaApi;
14+
import com.espacogeek.geek.models.ExternalReferenceModel;
15+
import com.espacogeek.geek.models.TypeReferenceModel;
16+
import com.espacogeek.geek.models.MediaModel;
17+
import com.espacogeek.geek.services.ExternalReferenceService;
18+
import com.espacogeek.geek.services.MediaService;
19+
import com.espacogeek.geek.services.AlternativeTitlesService;
20+
import com.espacogeek.geek.services.TypeReferenceService;
21+
import com.espacogeek.geek.data.MediaDataController.ExternalReferenceType;
22+
23+
@Component
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
public class MovieItemWriter implements ItemWriter<MediaModel> {
27+
private final MediaService mediaService;
28+
private final ExternalReferenceService externalReferenceService;
29+
private final AlternativeTitlesService alternativeTitlesService;
30+
@Qualifier("movieAPI")
31+
private final MediaApi movieApi;
32+
private final TypeReferenceService typeReferenceService;
33+
34+
private TypeReferenceModel typeReference;
35+
36+
@PostConstruct
37+
public void init() {
38+
try {
39+
this.typeReference = typeReferenceService.findById(ExternalReferenceType.TMDB.getId())
40+
.orElse(null);
41+
} catch (Exception e) {
42+
this.typeReference = null;
43+
}
44+
}
45+
46+
public void write(List<? extends MediaModel> items) {
47+
if (items == null || items.isEmpty()) return;
48+
49+
// Persist media objects in batch
50+
List<MediaModel> toSave = new ArrayList<>(items);
51+
List<MediaModel> saved;
52+
try {
53+
saved = mediaService.saveAll(toSave);
54+
} catch (Exception e) {
55+
log.error("Failed to save media batch: {}", e.getMessage(), e);
56+
throw e;
57+
}
58+
59+
// Collect external references to save (associate to persisted media)
60+
List<ExternalReferenceModel> refsToSave = new ArrayList<>();
61+
for (int i = 0; i < items.size(); i++) {
62+
MediaModel original = items.get(i);
63+
MediaModel persisted = saved.size() > i ? saved.get(i) : null;
64+
if (persisted == null) continue;
65+
66+
if (original.getExternalReference() != null) {
67+
for (ExternalReferenceModel ref : original.getExternalReference()) {
68+
ref.setMedia(persisted);
69+
refsToSave.add(ref);
70+
}
71+
}
72+
73+
// fetch and persist alternative titles using the external API id (if present)
74+
try {
75+
String externalId = null;
76+
if (original.getExternalReference() != null && !original.getExternalReference().isEmpty()) {
77+
externalId = original.getExternalReference().get(0).getReference();
78+
}
79+
if (externalId != null) {
80+
var alts = movieApi.getAlternativeTitles(Integer.valueOf(externalId));
81+
if (alts != null && !alts.isEmpty()) {
82+
alternativeTitlesService.saveAll(alts);
83+
persisted.setAlternativeTitles(alts);
84+
}
85+
}
86+
} catch (Exception e) {
87+
log.error("Failed to fetch/save alternative titles: {}", e.getMessage());
88+
}
89+
}
90+
91+
if (!refsToSave.isEmpty()) {
92+
try {
93+
externalReferenceService.saveAll(refsToSave);
94+
} catch (Exception e) {
95+
log.error("Failed to save external references: {}", e.getMessage(), e);
96+
}
97+
}
98+
}
99+
100+
// Spring Batch 5+ uses Chunk-based ItemWriter signature. Delegate to the list-based implementation if needed.
101+
@Override
102+
public void write(org.springframework.batch.item.Chunk<? extends MediaModel> chunk) throws Exception {
103+
if (chunk == null) return;
104+
write(chunk.getItems());
105+
}
106+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.espacogeek.geek.batch;
2+
3+
import org.springframework.batch.item.ExecutionContext;
4+
import org.springframework.batch.item.support.AbstractItemStreamItemReader;
5+
import org.springframework.batch.core.configuration.annotation.StepScope;
6+
import org.springframework.beans.factory.annotation.Qualifier;
7+
import org.springframework.stereotype.Component;
8+
import org.json.simple.JSONArray;
9+
import org.json.simple.JSONObject;
10+
import com.espacogeek.geek.data.api.MediaApi;
11+
import lombok.RequiredArgsConstructor;
12+
13+
/**
14+
* ItemReader that reads JSONObjects from the JSONArray returned by MediaApi.updateTitles().
15+
* Persists the current index in the Step ExecutionContext for restartability.
16+
*/
17+
@StepScope
18+
@Component
19+
@RequiredArgsConstructor
20+
public class MovieJsonReader extends AbstractItemStreamItemReader<JSONObject> {
21+
@Qualifier("movieAPI")
22+
private final MediaApi movieApi;
23+
24+
private static final String KEY_NEXT_INDEX = "movieJsonReader.nextIndex";
25+
26+
private JSONArray movies = new JSONArray();
27+
private int nextIndex = 0;
28+
29+
@Override
30+
public void open(ExecutionContext executionContext) {
31+
if (executionContext.containsKey(KEY_NEXT_INDEX)) {
32+
this.nextIndex = executionContext.getInt(KEY_NEXT_INDEX);
33+
} else {
34+
this.nextIndex = 0;
35+
}
36+
37+
try {
38+
var result = movieApi.updateTitles();
39+
if (result != null) {
40+
this.movies = result;
41+
}
42+
} catch (Exception e) {
43+
this.movies = new JSONArray();
44+
}
45+
}
46+
47+
@Override
48+
public void update(ExecutionContext executionContext) {
49+
executionContext.putInt(KEY_NEXT_INDEX, this.nextIndex);
50+
}
51+
52+
@Override
53+
public JSONObject read() {
54+
if (movies == null || nextIndex >= movies.size()) {
55+
return null;
56+
}
57+
return (JSONObject) movies.get(nextIndex++);
58+
}
59+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.espacogeek.geek.batch;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import jakarta.annotation.PostConstruct;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.json.simple.JSONObject;
10+
import org.springframework.batch.core.configuration.annotation.StepScope;
11+
import org.springframework.batch.item.ItemProcessor;
12+
import org.springframework.beans.factory.annotation.Qualifier;
13+
import org.springframework.stereotype.Component;
14+
15+
import com.espacogeek.geek.exception.GenericException;
16+
import com.espacogeek.geek.data.api.MediaApi;
17+
import com.espacogeek.geek.models.ExternalReferenceModel;
18+
import com.espacogeek.geek.models.TypeReferenceModel;
19+
import com.espacogeek.geek.models.MediaModel;
20+
import com.espacogeek.geek.models.MediaCategoryModel;
21+
import com.espacogeek.geek.services.ExternalReferenceService;
22+
import com.espacogeek.geek.services.MediaCategoryService;
23+
import com.espacogeek.geek.services.TypeReferenceService;
24+
import com.espacogeek.geek.data.MediaDataController.ExternalReferenceType;
25+
import com.espacogeek.geek.data.MediaDataController.MediaType;
26+
27+
@Component
28+
@StepScope
29+
@RequiredArgsConstructor
30+
@Slf4j
31+
public class MovieProcessor implements ItemProcessor<JSONObject, MediaModel> {
32+
@Qualifier("movieAPI")
33+
private final MediaApi movieApi;
34+
private final MediaCategoryService mediaCategoryService;
35+
private final ExternalReferenceService externalReferenceService;
36+
private final TypeReferenceService typeReferenceService;
37+
38+
private TypeReferenceModel typeReference;
39+
private MediaCategoryModel mediaMovieCategory;
40+
private MediaCategoryModel mediaAnimeCategory;
41+
private MediaCategoryModel mediaUndefinedCategory;
42+
43+
@PostConstruct
44+
public void init() {
45+
this.typeReference = typeReferenceService.findById(ExternalReferenceType.TMDB.getId())
46+
.orElseThrow(() -> new GenericException("Type Reference not found"));
47+
48+
this.mediaMovieCategory = mediaCategoryService.findById(MediaType.MOVIE.getId())
49+
.orElseThrow(() -> new GenericException("Category not found"));
50+
this.mediaAnimeCategory = mediaCategoryService.findById(MediaType.ANIME_MOVIE.getId())
51+
.orElseThrow(() -> new GenericException("Category not found"));
52+
this.mediaUndefinedCategory = mediaCategoryService.findById(MediaType.UNDEFINED_MEDIA.getId())
53+
.orElseThrow(() -> new GenericException("Category not found"));
54+
}
55+
56+
@Override
57+
public MediaModel process(JSONObject json) {
58+
if (json == null) return null;
59+
60+
String idStr = json.get("id").toString();
61+
62+
var existing = externalReferenceService.findByReferenceAndType(idStr, typeReference);
63+
if (existing.isPresent()) {
64+
// already exists, skip this item
65+
return null;
66+
}
67+
68+
boolean isAnime = false;
69+
boolean isUndefined = false;
70+
try {
71+
isAnime = movieApi.getKeyword(Integer.valueOf(idStr)).stream()
72+
.anyMatch(k -> k.getName().equalsIgnoreCase("anime"));
73+
} catch (Exception e) {
74+
log.error("Error fetching keywords for movie ID {}: {}", idStr, e.getMessage());
75+
isUndefined = true;
76+
}
77+
78+
MediaModel media = new MediaModel();
79+
if (isAnime) media.setMediaCategory(mediaAnimeCategory);
80+
else if (isUndefined) media.setMediaCategory(mediaUndefinedCategory);
81+
else media.setMediaCategory(mediaMovieCategory);
82+
83+
var name = json.get("original_title") != null ? json.get("original_title").toString() : json.get("original_name") != null ? json.get("original_name").toString() : null;
84+
media.setName(name);
85+
86+
ExternalReferenceModel externalReference = new ExternalReferenceModel();
87+
externalReference.setTypeReference(typeReference);
88+
externalReference.setReference(idStr);
89+
90+
List<ExternalReferenceModel> refs = new ArrayList<>();
91+
refs.add(externalReference);
92+
media.setExternalReference(refs);
93+
94+
return media;
95+
}
96+
}

0 commit comments

Comments
 (0)