Skip to content

Commit 653816a

Browse files
author
FirstGameLife
committed
Add TV series support, stalled-download retry, and i18n
Movies and TV series are now searched together (or separately via /film and /serie), with season/episode selection, dual-title indexer search (original and localized title), and a manual override for shows TMDB miscounts. Downloads stuck at 0 seeders for 2 hours are flagged with a button to stop and retry. Bot messages are now bilingual (BOT_LANGUAGE=en/it) with English aliases for every command, and MEDIA_ROOT_FOLDER/MEDIA_TV_ROOT_FOLDER are now required instead of silently defaulting to a path that may not exist.
1 parent 01de9c2 commit 653816a

32 files changed

Lines changed: 2063 additions & 325 deletions

src/main/java/com/cineseekerr/bot/bot/ConversationHandler.java

Lines changed: 404 additions & 123 deletions
Large diffs are not rendered by default.

src/main/java/com/cineseekerr/bot/bot/MessageFormatter.java

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,29 @@
77
import com.cineseekerr.bot.model.ReleaseSource;
88
import com.cineseekerr.bot.model.Resolution;
99
import com.cineseekerr.bot.model.SearchResult;
10-
import com.cineseekerr.bot.model.TmdbMovie;
10+
import com.cineseekerr.bot.model.TmdbTitle;
1111
import com.cineseekerr.bot.model.VideoCodec;
12+
import org.springframework.stereotype.Component;
1213

14+
import java.time.Duration;
1315
import java.util.ArrayList;
1416
import java.util.List;
1517
import java.util.Locale;
1618
import java.util.stream.Collectors;
1719

18-
/** Builds the HTML texts the bot sends. All user-controlled content goes through {@link #esc}. */
19-
public final class MessageFormatter {
20+
/**
21+
* Builds the HTML texts the bot sends, in the language configured via
22+
* {@link Messages}. All user-controlled content goes through {@link #esc}.
23+
*/
24+
@Component
25+
public class MessageFormatter {
2026

2127
static final String[] NUMBER_EMOJI = {"1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"};
2228

23-
private MessageFormatter() {
29+
private final Messages messages;
30+
31+
public MessageFormatter(Messages messages) {
32+
this.messages = messages;
2433
}
2534

2635
public static String esc(String text) {
@@ -30,19 +39,25 @@ public static String esc(String text) {
3039
.replace(">", ">");
3140
}
3241

33-
static String movieLabel(TmdbMovie movie) {
34-
return movie.year() == null ? movie.title() : movie.title() + " (" + movie.year() + ")";
42+
static String titleLabel(TmdbTitle title) {
43+
return title.year() == null ? title.title() : title.title() + " (" + title.year() + ")";
44+
}
45+
46+
/** 📺 for TV series, 🎬 for movies. */
47+
static String icon(TmdbTitle title) {
48+
return title.isTv() ? "📺" : "🎬";
3549
}
3650

37-
static String candidatesText(List<TmdbMovie> candidates) {
38-
StringBuilder sb = new StringBuilder("🎬 <b>Quale film intendi?</b>\n\n");
51+
String candidatesText(List<TmdbTitle> candidates) {
52+
StringBuilder sb = new StringBuilder(messages.get("candidates.header")).append("\n\n");
3953
for (int i = 0; i < candidates.size(); i++) {
40-
TmdbMovie movie = candidates.get(i);
41-
sb.append(NUMBER_EMOJI[i]).append(' ').append("<b>").append(esc(movieLabel(movie))).append("</b>");
42-
if (movie.posterUrl() != null) {
43-
sb.append(" <a href=\"").append(movie.posterUrl()).append("\">🖼</a>");
54+
TmdbTitle title = candidates.get(i);
55+
sb.append(NUMBER_EMOJI[i]).append(' ').append(icon(title)).append(' ')
56+
.append("<b>").append(esc(titleLabel(title))).append("</b>");
57+
if (title.posterUrl() != null) {
58+
sb.append(" <a href=\"").append(title.posterUrl()).append("\">🖼</a>");
4459
}
45-
String overview = movie.overview();
60+
String overview = title.overview();
4661
if (overview != null && !overview.isBlank()) {
4762
sb.append('\n').append("<i>").append(esc(truncate(overview, 120))).append("</i>");
4863
}
@@ -51,35 +66,43 @@ static String candidatesText(List<TmdbMovie> candidates) {
5166
return sb.toString().stripTrailing();
5267
}
5368

54-
static String qualityLabel(Resolution resolution) {
55-
return resolution == Resolution.UNKNOWN ? "Altro" : resolution.label();
69+
String qualityLabel(Resolution resolution) {
70+
return resolution == Resolution.UNKNOWN ? messages.get("quality.other") : resolution.label();
5671
}
5772

5873
static String subtitleLabel(Language language) {
5974
return "SUB " + language.label();
6075
}
6176

62-
/** e.g. {@code "2160p · ITA · SUB ITA"} or {@code "nessun filtro"}. */
63-
static String filterRecap(ConversationState state) {
77+
/** e.g. {@code "ITA · SUB ITA · 2160p"} (same order as the filter steps) or {@code "no filters"}. */
78+
String filterRecap(ConversationState state) {
6479
List<String> parts = new ArrayList<>();
65-
if (state.qualityFilter() != null) {
66-
parts.add(qualityLabel(state.qualityFilter()));
67-
}
6880
if (state.audioFilter() != null) {
6981
parts.add(state.audioFilter().label());
7082
}
7183
if (state.subtitleFilter() != null) {
7284
parts.add(subtitleLabel(state.subtitleFilter()));
7385
}
74-
return parts.isEmpty() ? "nessun filtro" : String.join(" · ", parts);
86+
if (state.qualityFilter() != null) {
87+
parts.add(qualityLabel(state.qualityFilter()));
88+
}
89+
return parts.isEmpty() ? messages.get("recap.none") : String.join(" · ", parts);
7590
}
7691

77-
static String stepHeader(ConversationState state) {
78-
return "🎬 <b>" + esc(movieLabel(state.movie())) + "</b> — "
79-
+ state.filtered().size() + " release (" + filterRecap(state) + ")\n\n";
92+
String stepHeader(ConversationState state) {
93+
String label = titleLabel(state.title());
94+
if (state.season() != null) {
95+
label += " — " + messages.get("recap.season", state.season());
96+
}
97+
if (state.episode() != null) {
98+
label += " — " + messages.get("recap.episode", state.episode());
99+
}
100+
return icon(state.title()) + " <b>" + esc(label) + "</b> — "
101+
+ messages.get("step.header.releases", state.filtered().size(), filterRecap(state))
102+
+ "\n\n";
80103
}
81104

82-
static String releaseSummary(SearchResult result) {
105+
String releaseSummary(SearchResult result) {
83106
ParsedRelease parsed = result.parsed();
84107
List<String> tech = new ArrayList<>();
85108
if (parsed.resolution() != Resolution.UNKNOWN) {
@@ -92,7 +115,7 @@ static String releaseSummary(SearchResult result) {
92115
tech.add(parsed.codec().label());
93116
}
94117
String audio = parsed.audioLanguages().isEmpty()
95-
? "audio ?"
118+
? messages.get("release.audio.unknown")
96119
: parsed.audioLanguages().stream().map(Language::label).collect(Collectors.joining(" "));
97120
tech.add("🔊 " + audio);
98121
if (parsed.subtitled()) {
@@ -106,9 +129,9 @@ static String releaseSummary(SearchResult result) {
106129
return String.join(" · ", tech);
107130
}
108131

109-
static String shortlistText(ConversationState state) {
132+
String shortlistText(ConversationState state) {
110133
StringBuilder sb = new StringBuilder(stepHeader(state))
111-
.append("🎯 <b>Migliori release per seeders:</b>\n\n");
134+
.append(messages.get("shortlist.header")).append("\n\n");
112135
List<SearchResult> shortlist = state.shortlist();
113136
for (int i = 0; i < shortlist.size(); i++) {
114137
SearchResult result = shortlist.get(i);
@@ -122,19 +145,19 @@ static String shortlistText(ConversationState state) {
122145
.append("<code>").append(esc(truncate(result.release().title(), 80))).append("</code>")
123146
.append("\n\n");
124147
}
125-
sb.append("Quale scarico?");
148+
sb.append(messages.get("shortlist.prompt"));
126149
return sb.toString();
127150
}
128151

129-
static String torrentStatusLine(QbtTorrent torrent) {
152+
String torrentStatusLine(QbtTorrent torrent) {
130153
String name = truncate(torrent.name(), 60);
131154
if (torrent.isComplete()) {
132-
return "✅ <b>" + esc(name) + "</b> — completato";
155+
return messages.get("status.line.done", esc(name));
133156
}
134157
int percent = (int) Math.floor(torrent.progress() * 100);
135158
long speed = torrent.dlspeed() == null ? 0 : torrent.dlspeed();
136-
return "⬇️ <b>" + esc(name) + "</b> — " + percent + "% ("
137-
+ humanSize(speed) + "/s, ETA " + humanEta(torrent.eta()) + ")";
159+
return messages.get("status.line.progress", esc(name), String.valueOf(percent),
160+
humanSize(speed), humanEta(torrent.eta()));
138161
}
139162

140163
static String humanSize(long bytes) {
@@ -147,6 +170,14 @@ static String humanSize(long bytes) {
147170
return String.format(Locale.ROOT, "%.0f KB", bytes / 1024.0);
148171
}
149172

173+
/** e.g. {@code "2h 15m"} or {@code "40m"}. */
174+
public static String humanDuration(Duration duration) {
175+
long totalMinutes = Math.max(0, duration.toMinutes());
176+
long hours = totalMinutes / 60;
177+
long minutes = totalMinutes % 60;
178+
return hours > 0 ? hours + "h " + minutes + "m" : minutes + "m";
179+
}
180+
150181
/** qBittorrent reports 8640000 seconds when the ETA is unknown. */
151182
static String humanEta(Long etaSeconds) {
152183
if (etaSeconds == null || etaSeconds >= 8_640_000L || etaSeconds < 0) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.cineseekerr.bot.bot;
2+
3+
import com.cineseekerr.bot.config.CineSeekerrProperties;
4+
import org.springframework.context.support.ResourceBundleMessageSource;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.util.Locale;
8+
9+
/**
10+
* Resolves the bot's user-facing texts from {@code messages*.properties} in the language
11+
* configured via {@code BOT_LANGUAGE}. English ({@code messages.properties}) is the base
12+
* bundle and the fallback for untranslated languages.
13+
*/
14+
@Component
15+
public class Messages {
16+
17+
private final ResourceBundleMessageSource source;
18+
private final Locale locale;
19+
20+
public Messages(CineSeekerrProperties properties) {
21+
this.source = new ResourceBundleMessageSource();
22+
source.setBasename("messages");
23+
source.setDefaultEncoding("UTF-8");
24+
// never fall back to the JVM's locale: BOT_LANGUAGE is the only selector
25+
source.setFallbackToSystemLocale(false);
26+
this.locale = Locale.forLanguageTag(properties.language());
27+
}
28+
29+
/**
30+
* The text for {@code key}, with {@code {0}}-style placeholders replaced by
31+
* {@code args}. Keys are static and kept in sync with the bundles, so a missing key is
32+
* a programming error and throws.
33+
*/
34+
public String get(String key, Object... args) {
35+
return source.getMessage(key, args, locale);
36+
}
37+
}

src/main/java/com/cineseekerr/bot/bot/download/DownloadTracker.java

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.cineseekerr.bot.bot.download;
22

3+
import com.cineseekerr.bot.model.TmdbTitle;
4+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
35
import com.fasterxml.jackson.databind.ObjectMapper;
46
import org.slf4j.Logger;
57
import org.slf4j.LoggerFactory;
@@ -13,6 +15,7 @@
1315
import java.util.List;
1416
import java.util.Locale;
1517
import java.util.Map;
18+
import java.util.Optional;
1619
import java.util.concurrent.ConcurrentHashMap;
1720

1821
/**
@@ -35,11 +38,18 @@ public class DownloadTracker {
3538
/**
3639
* @param chatId chat to notify on completion
3740
* @param releaseTitle release name as reported by Prowlarr
38-
* @param plexName display name shown in notifications (e.g. "Title (Year)"); also
39-
* the rename target
41+
* @param plexName display name shown in notifications (e.g. "Title (Year)" or
42+
* "Show (Year) — Stagione 2"); also the rename target for movies
43+
* @param tmdbTitle the TMDB title this download is for, used to search again if the
44+
* download stalls; {@code null} for entries tracked without one
45+
* @param season season number for a TV download; {@code null} for movies
46+
* @param episode episode number for a single-episode download; {@code null} for
47+
* whole-season packs and movies
4048
* @param addedAt when the download was queued, used to expire unmatched entries
4149
*/
42-
public record PendingDownload(long chatId, String releaseTitle, String plexName, Instant addedAt) {
50+
@JsonIgnoreProperties(ignoreUnknown = true)
51+
public record PendingDownload(long chatId, String releaseTitle, String plexName, TmdbTitle tmdbTitle,
52+
Integer season, Integer episode, Instant addedAt) {
4353
}
4454

4555
private final Map<String, PendingDownload> pending = new ConcurrentHashMap<>();
@@ -54,11 +64,22 @@ public DownloadTracker(ObjectMapper objectMapper,
5464
}
5565

5666
public void track(long chatId, String releaseTitle, String plexName) {
57-
track(chatId, releaseTitle, plexName, Instant.now());
67+
track(chatId, releaseTitle, plexName, null, null, null, Instant.now());
5868
}
5969

6070
public void track(long chatId, String releaseTitle, String plexName, Instant addedAt) {
61-
pending.put(normalize(releaseTitle), new PendingDownload(chatId, releaseTitle, plexName, addedAt));
71+
track(chatId, releaseTitle, plexName, null, null, null, addedAt);
72+
}
73+
74+
public void track(long chatId, String releaseTitle, String plexName, TmdbTitle tmdbTitle, Integer season,
75+
Integer episode) {
76+
track(chatId, releaseTitle, plexName, tmdbTitle, season, episode, Instant.now());
77+
}
78+
79+
public void track(long chatId, String releaseTitle, String plexName, TmdbTitle tmdbTitle, Integer season,
80+
Integer episode, Instant addedAt) {
81+
pending.put(normalize(releaseTitle),
82+
new PendingDownload(chatId, releaseTitle, plexName, tmdbTitle, season, episode, addedAt));
6283
persist();
6384
}
6485

@@ -71,6 +92,23 @@ public void remove(String key) {
7192
persist();
7293
}
7394

95+
/**
96+
* Finds the pending download whose release title matches a qBittorrent torrent name —
97+
* used to resolve the "retry search" button on a stalled-download notification, where
98+
* only the torrent hash is available.
99+
*/
100+
public Optional<Map.Entry<String, PendingDownload>> findByTorrentName(String torrentName) {
101+
String normalized = normalize(torrentName);
102+
if (normalized.isBlank()) {
103+
return Optional.empty();
104+
}
105+
return pending.entrySet().stream()
106+
.filter(e -> normalized.equals(e.getKey())
107+
|| normalized.contains(e.getKey())
108+
|| e.getKey().contains(normalized))
109+
.findFirst();
110+
}
111+
74112
public boolean isEmpty() {
75113
return pending.isEmpty();
76114
}

0 commit comments

Comments
 (0)