001package net.filebot.web;
002
003import static java.nio.charset.StandardCharsets.*;
004import static java.util.Arrays.*;
005import static java.util.Collections.*;
006import static java.util.stream.Collectors.*;
007import static net.filebot.CachedResource.fetchIfModified;
008import static net.filebot.Logging.*;
009import static net.filebot.util.JsonUtilities.*;
010import static net.filebot.util.StringUtilities.*;
011import static net.filebot.web.EpisodeUtilities.*;
012import static net.filebot.web.WebRequest.*;
013
014import java.net.URI;
015import java.net.URL;
016import java.nio.ByteBuffer;
017import java.time.Duration;
018import java.time.Instant;
019import java.util.ArrayList;
020import java.util.LinkedHashMap;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.stream.Stream;
027
028import javax.swing.Icon;
029
030import net.filebot.Cache;
031import net.filebot.CacheType;
032import net.filebot.ResourceManager;
033
034public class TheTVDBClient extends AbstractEpisodeListProvider implements ArtworkProvider {
035
036        private static URL getEndpoint() {
037                try {
038                        return new URL(System.getProperty("net.filebot.TheTVDB.url", "https://api.thetvdb.com/"));
039                } catch (Exception e) {
040                        throw new IllegalStateException(e);
041                }
042        }
043
044        private static URL getBannerMirror() {
045                try {
046                        return new URL(System.getProperty("net.filebot.TheTVDB.banner.url", "https://www.thetvdb.com/banners/"));
047                } catch (Exception e) {
048                        throw new IllegalStateException(e);
049                }
050        }
051
052        private static final Locale DEFAULT_LOCALE = Locale.ENGLISH;
053
054        private final URL api;
055        private final String apikey;
056
057        public TheTVDBClient(URL api, String apikey) {
058                this.api = api;
059                this.apikey = apikey;
060        }
061
062        public TheTVDBClient(String apikey) {
063                this(getEndpoint(), apikey);
064        }
065
066        @Override
067        public String getIdentifier() {
068                return "TheTVDB";
069        }
070
071        @Override
072        public Icon getIcon() {
073                return ResourceManager.getIcon("search.thetvdb");
074        }
075
076        @Override
077        public boolean hasSeasonSupport() {
078                return true;
079        }
080
081        protected Object postJson(String path, Object object) throws Exception {
082                // curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' 'https://api.thetvdb.com/login' --data '{"apikey":"XXXXX"}'
083                ByteBuffer response = post(getEndpoint(path), json(object, false).getBytes(UTF_8), "application/json", null);
084                return readJson(UTF_8.decode(response));
085        }
086
087        protected Object requestJson(String path, Locale locale, Duration expirationTime) throws Exception {
088                Cache cache = Cache.getCache(locale == null || locale == Locale.ROOT ? getName() : getName() + "_" + locale.getLanguage(), CacheType.Monthly);
089                return cache.json(path, this::getEndpoint).fetch(fetchIfModified(() -> getRequestHeader(locale))).expire(expirationTime).get();
090        }
091
092        protected URL getEndpoint(String path) throws Exception {
093                return new URL(api, path);
094        }
095
096        private Map<String, String> getRequestHeader(Locale locale) {
097                Map<String, String> header = new LinkedHashMap<String, String>(3);
098
099                getLanguageCode(locale).ifPresent(languageCode -> {
100                        header.put("Accept-Language", languageCode);
101                });
102
103                header.put("Accept", "application/json");
104                header.put("Authorization", "Bearer " + getAuthorizationToken());
105
106                return header;
107        }
108
109        private Optional<String> getLanguageCode(Locale locale) {
110                // Note: ISO 639 is not a stable standard— some languages' codes have changed.
111                // Locale's constructor recognizes both the new and the old codes for the languages whose codes have changed,
112                // but this function always returns the old code.
113                return Optional.ofNullable(locale).map(Locale::getLanguage).map(code -> {
114                        switch (code) {
115                        case "iw":
116                                return "he"; // Hebrew
117                        case "in":
118                                return "id"; // Indonesian
119                        case "":
120                                return null; // empty language code
121                        default:
122                                return code;
123                        }
124                });
125        }
126
127        private String token = null;
128        private Instant tokenExpireInstant = null;
129        private Duration tokenExpireDuration = Duration.ofHours(23); // token expires after 24 hours
130
131        private String getAuthorizationToken() {
132                synchronized (tokenExpireDuration) {
133                        if (token == null || (tokenExpireInstant != null && Instant.now().isAfter(tokenExpireInstant))) {
134                                try {
135                                        Object json = postJson("login", singletonMap("apikey", apikey));
136                                        token = getString(json, "token");
137                                        tokenExpireInstant = Instant.now().plus(tokenExpireDuration);
138                                } catch (Exception e) {
139                                        throw new IllegalStateException("Failed to retrieve authorization token: " + e.getMessage(), e);
140                                }
141                        }
142                        return token;
143                }
144        }
145
146        protected List<SearchResult> search(String path, Map<String, Object> query, Locale locale, Duration expirationTime) throws Exception {
147                Object json = requestJson(path + "?" + encodeParameters(query, true), locale, expirationTime);
148
149                return streamJsonObjects(json, "data").map(it -> {
150                        // e.g. aliases, banner, firstAired, id, network, overview, seriesName, status
151                        int id = getInteger(it, "id");
152                        String seriesName = getString(it, "seriesName");
153
154                        if (seriesName == null || seriesName.startsWith("**") || seriesName.endsWith("**")) {
155                                debug.warning(format("Ignore invalid series: %s [%d]", seriesName, id));
156                                return null;
157                        }
158
159                        String[] aliasNames = stream(getArray(it, "aliases")).toArray(String[]::new);
160                        String slug = getString(it, "slug");
161                        String network = getString(it, "network");
162                        String status = getString(it, "status");
163                        SimpleDate firstAired = getStringValue(it, "firstAired", SimpleDate::parse);
164                        String overview = getString(it, "overview");
165
166                        return new TheTVDBSearchResult(id, seriesName, aliasNames, slug, firstAired, overview, network, status);
167                }).filter(Objects::nonNull).collect(toList());
168        }
169
170        @Override
171        public List<SearchResult> fetchSearchResult(String query, Locale locale) throws Exception {
172                return search("search/series", singletonMap("name", query), locale, Cache.ONE_DAY);
173        }
174
175        @Override
176        public TheTVDBSeriesInfo getSeriesInfo(int id, Locale language) throws Exception {
177                return getSeriesInfo(new SearchResult(id), language);
178        }
179
180        public Object requestSeriesInfo(int id, Locale locale) throws Exception {
181                return requestJson("series/" + id, locale, Cache.ONE_WEEK);
182        }
183
184        @Override
185        public TheTVDBSeriesInfo getSeriesInfo(SearchResult series, Locale locale) throws Exception {
186                Object data = getMap(requestSeriesInfo(series.getId(), locale), "data");
187
188                TheTVDBSeriesInfo info = new TheTVDBSeriesInfo(this, locale, series.getId());
189                info.setSlug(getString(data, "slug"));
190                info.setAliasNames(Stream.of(series.getAliasNames(), getArray(data, "aliases")).flatMap(it -> stream(it)).map(Object::toString).distinct().toArray(String[]::new));
191
192                info.setName(getString(data, "seriesName"));
193                info.setCertification(getString(data, "rating"));
194                info.setNetwork(getString(data, "network"));
195                info.setStatus(getString(data, "status"));
196
197                info.setRating(getDouble(data, "siteRating"));
198                info.setRatingCount(getInteger(data, "siteRatingCount"));
199
200                info.setRuntime(matchInteger(getString(data, "runtime")));
201                info.setGenres(stream(getArray(data, "genre")).map(Object::toString).collect(toList()));
202                info.setStartDate(getStringValue(data, "firstAired", SimpleDate::parse));
203
204                // TheTVDB SeriesInfo extras
205                info.setImdbId(getString(data, "imdbId"));
206                info.setOverview(getString(data, "overview"));
207                info.setAirsDayOfWeek(getString(data, "airsDayOfWeek"));
208                info.setAirsTime(getString(data, "airsTime"));
209                info.setBannerUrl(getStringValue(data, "banner", this::resolveImage));
210                info.setLastUpdated(getStringValue(data, "lastUpdated", Long::parseLong));
211
212                return info;
213        }
214
215        @Override
216        protected SeriesData fetchSeriesData(SearchResult series, SortOrder sortOrder, Locale locale) throws Exception {
217                // fetch series info
218                SeriesInfo info = getSeriesInfo(series, locale);
219                info.setOrder(sortOrder.name());
220
221                // ignore preferred language if basic series information isn't even available
222                if (info.getName() == null && !locale.equals(DEFAULT_LOCALE)) {
223                        return fetchSeriesData(series, sortOrder, DEFAULT_LOCALE);
224                }
225
226                // if series name isn't even available in English then just use whatever value we've got
227                if (info.getName() == null) {
228                        info.setName(series.getName());
229                }
230
231                // fetch episode data
232                List<Episode> episodes = new ArrayList<Episode>();
233                List<Episode> specials = new ArrayList<Episode>();
234
235                for (int i = 1, n = 1; i <= n; i++) {
236                        Object json = requestJson("series/" + series.getId() + "/episodes?page=" + i, locale, Cache.ONE_DAY);
237
238                        Integer lastPage = getInteger(getMap(json, "links"), "last");
239                        if (lastPage != null) {
240                                n = lastPage;
241                        }
242
243                        streamJsonObjects(json, "data").forEach(it -> {
244                                Integer id = getInteger(it, "id");
245                                String episodeName = getString(it, "episodeName");
246
247                                // default to English episode title if the preferred language is not available
248                                if (episodeName == null && !locale.equals(DEFAULT_LOCALE)) {
249                                        try {
250                                                episodeName = getEpisodeList(series, sortOrder, DEFAULT_LOCALE).stream().filter(e -> id.equals(e.getId())).findFirst().map(Episode::getTitle).orElse(null);
251                                        } catch (Exception e) {
252                                                debug.warning(cause("Failed to retrieve default episode title", e));
253                                        }
254                                }
255
256                                Integer absoluteNumber = getInteger(it, "absoluteNumber");
257                                SimpleDate airdate = getStringValue(it, "firstAired", SimpleDate::parse);
258
259                                // default numbering
260                                Integer episodeNumber = getInteger(it, "airedEpisodeNumber");
261                                Integer seasonNumber = getInteger(it, "airedSeason");
262
263                                // adjust for forced absolute numbering (if possible)
264                                if (sortOrder == SortOrder.DVD) {
265                                        Integer dvdSeasonNumber = getInteger(it, "dvdSeason");
266                                        Number dvdEpisodeNumber = getDecimal(it, "dvdEpisodeNumber"); // e.g. 4.2
267
268                                        // require both values to be valid integer numbers
269                                        if (dvdSeasonNumber != null && dvdEpisodeNumber != null) {
270                                                seasonNumber = dvdSeasonNumber;
271                                                episodeNumber = dvdEpisodeNumber.intValue();
272
273                                                if (episodeNumber.doubleValue() != dvdEpisodeNumber.doubleValue()) {
274                                                        debug.finest(format("[%s] Coerce episode number [%s] to [%s]", info, dvdEpisodeNumber, episodeNumber));
275                                                }
276                                        }
277                                } else if (sortOrder == SortOrder.Absolute && absoluteNumber != null && absoluteNumber > 0) {
278                                        seasonNumber = null;
279                                        episodeNumber = absoluteNumber;
280                                } else if (sortOrder == SortOrder.AbsoluteAirdate && airdate != null) {
281                                        // use airdate as absolute episode number
282                                        seasonNumber = null;
283                                        episodeNumber = airdate.getYear() * 1_00_00 + airdate.getMonth() * 1_00 + airdate.getDay();
284                                }
285
286                                if (seasonNumber == null || seasonNumber > 0) {
287                                        // handle as normal episode
288                                        episodes.add(new Episode(info.getName(), seasonNumber, episodeNumber, episodeName, absoluteNumber, null, airdate, id, new SeriesInfo(info)));
289                                } else {
290                                        // handle as special episode
291                                        specials.add(new Episode(info.getName(), null, null, episodeName, absoluteNumber, episodeNumber, airdate, id, new SeriesInfo(info)));
292                                }
293                        });
294                }
295
296                // episodes my not be ordered by DVD episode number
297                episodes.sort(episodeComparator());
298
299                // add specials at the end
300                episodes.addAll(specials);
301
302                return new SeriesData(info, episodes);
303        }
304
305        public SearchResult lookupByID(int id, Locale locale) throws Exception {
306                if (id <= 0) {
307                        throw new IllegalArgumentException("Illegal TheTVDB ID: " + id);
308                }
309
310                SeriesInfo info = getSeriesInfo(new SearchResult(id), locale);
311                return new SearchResult(id, info.getName(), info.getAliasNames());
312        }
313
314        public SearchResult lookupByIMDbID(int imdbid, Locale locale) throws Exception {
315                if (imdbid <= 0) {
316                        throw new IllegalArgumentException("Illegal IMDbID ID: " + imdbid);
317                }
318
319                List<SearchResult> result = search("search/series", singletonMap("imdbId", String.format("tt%07d", imdbid)), locale, Cache.ONE_MONTH);
320                return result.size() > 0 ? result.get(0) : null;
321        }
322
323        @Override
324        public URI getEpisodeListLink(SearchResult searchResult) {
325                return URI.create("https://www.thetvdb.com/?tab=seasonall&id=" + searchResult.getId());
326        }
327
328        @Override
329        public List<Artwork> getArtwork(int id, String category, Locale locale) throws Exception {
330                Object json = requestJson("series/" + id + "/images/query?keyType=" + category, locale, Cache.ONE_MONTH);
331
332                return streamJsonObjects(json, "data").map(it -> {
333                        String subKey = getString(it, "subKey");
334                        String resolution = getString(it, "resolution");
335                        URL url = getStringValue(it, "fileName", this::resolveImage);
336                        Double rating = getDouble(getMap(it, "ratingsInfo"), "average");
337
338                        if (url == null) {
339                                debug.warning(message("Bad artwork response", it));
340                                return null;
341                        }
342
343                        return new Artwork(Stream.of(category, subKey, resolution), url, locale, rating);
344                }).filter(Objects::nonNull).sorted(Artwork.RATING_ORDER).collect(toList());
345        }
346
347        protected URL resolveImage(String path) {
348                if (path == null || path.isEmpty()) {
349                        return null;
350                }
351
352                // TheTVDB API v2 does not have a dedicated banner mirror
353                try {
354                        return new URL(getBannerMirror(), path);
355                } catch (Exception e) {
356                        throw new IllegalArgumentException(path, e);
357                }
358        }
359
360        public List<String> getLanguages() throws Exception {
361                Object response = requestJson("languages", Locale.ROOT, Cache.ONE_MONTH);
362                return streamJsonObjects(response, "data").map(it -> getString(it, "abbreviation")).collect(toList());
363        }
364
365        public List<Person> getActors(int seriesId, Locale locale) throws Exception {
366                Object response = requestJson("series/" + seriesId + "/actors", locale, Cache.ONE_MONTH);
367
368                // e.g. [id:68414, seriesId:78874, name:Summer Glau, role:River Tam, sortOrder:2, image:actors/68414.jpg, imageAuthor:513, imageAdded:0000-00-00 00:00:00, lastUpdated:2011-08-18 11:53:14]
369                return streamJsonObjects(response, "data").map(it -> {
370                        String name = getString(it, "name");
371                        String character = getString(it, "role");
372                        Integer order = getInteger(it, "sortOrder");
373                        URL image = getStringValue(it, "image", this::resolveImage);
374
375                        return new Person(name, character, Person.ACTOR, null, order, image);
376                }).sorted(Person.CREDIT_ORDER).collect(toList());
377        }
378
379        public Object requestEpisodeInfo(int id, Locale locale) throws Exception {
380                return requestJson("episodes/" + id, locale, Cache.ONE_MONTH);
381        }
382
383        public EpisodeInfo getEpisodeInfo(int id, Locale locale) throws Exception {
384                Object data = getMap(requestEpisodeInfo(id, locale), "data");
385
386                Integer seriesId = getInteger(data, "seriesId");
387                String overview = getString(data, "overview");
388
389                Double rating = getDouble(data, "siteRating");
390                Integer votes = getInteger(data, "siteRatingCount");
391
392                List<Person> people = new ArrayList<Person>();
393
394                for (Object it : getArray(data, "directors")) {
395                        people.add(new Person(it.toString(), Person.DIRECTOR));
396                }
397                for (Object it : getArray(data, "writers")) {
398                        people.add(new Person(it.toString(), Person.WRITER));
399                }
400                for (Object it : getArray(data, "guestStars")) {
401                        people.add(new Person(it.toString(), Person.GUEST_STAR));
402                }
403
404                return new EpisodeInfo(this, locale, seriesId, id, people, overview, rating, votes);
405        }
406
407}