Initial implementation of kodik metadata extraction

This commit is contained in:
2025-06-01 23:56:48 +05:00
parent 637702dccf
commit 833c3d9fd4
13 changed files with 510 additions and 0 deletions

View File

@ -6,6 +6,7 @@ import retrofit2.http.GET;
import retrofit2.http.Query;
public interface KodikPlayerAPI {
String DEFAULT_PLAYER_NAME = "Player";
String BASE_PLAYER_URL = "https%3A%2F%2Fkodikdb.com%2Ffind-player%3F";
default String shikimoriURL(String id) {
@ -24,6 +25,22 @@ public interface KodikPlayerAPI {
return BASE_PLAYER_URL + "ID%3D" + id;
}
default Call<KodikPlayerResponse> getKodikIDPlayer(String id, String token) {
return getPlayer(DEFAULT_PLAYER_NAME, false, kodikIDURL(id), token, id, null, null, null);
}
default Call<KodikPlayerResponse> getShikimoriPlayer(String id, String token) {
return getPlayer(DEFAULT_PLAYER_NAME, false, shikimoriURL(id), token, null, id, null, null);
}
default Call<KodikPlayerResponse> getKinopoiskPlayer(String id, String token) {
return getPlayer(DEFAULT_PLAYER_NAME, false, kinopoiskURL(id), token, null, null, id, null);
}
default Call<KodikPlayerResponse> getIMDBPlayer(String id, String token) {
return getPlayer(DEFAULT_PLAYER_NAME, false, imdbURL(id), token, null, null, null, id);
}
@GET("get-player")
Call<KodikPlayerResponse> getPlayer(
@Query("title") String title,

View File

@ -0,0 +1,89 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.FormBody;
import okhttp3.RequestBody;
/**
* KodikAPIPayload
*/
public class KodikAPIPayload {
private static final Map<String, String> FIELD_PATTERNS = Map.of(
"d", "var\\s*domain\\s*=\\s*['\"]([^'\"]*)['\"];",
"d_sign", "var\\s*d_sign\\s*=\\s*['\"]([^'\"]*)['\"];",
"pd", "var\\s*pd\\s*=\\s*['\"]([^'\"]*)['\"];",
"pd_sign", "var\\s*pd_sign\\s*=\\s*['\"]([^'\"]*)['\"];",
"ref", "var\\s*ref\\s*=\\s*['\"]([^'\"]*)['\"];",
"ref_sign", "var\\s*ref_sign\\s*=\\s*['\"]([^'\"]*)['\"];",
"type", "videoInfo\\.type\\s*=\\s*['\"]([^'\"]*)['\"];",
"hash", "videoInfo\\.hash\\s*=\\s*['\"]([^'\"]*)['\"];",
"id", "videoInfo\\.id\\s*=\\s*['\"]([^'\"]*)['\"];");
private final String document;
private final Map<String, String> parsedData;
public KodikAPIPayload(String document) {
this.document = document;
this.parsedData = parseAllFields();
}
private Map<String, String> parseAllFields() {
Map<String, String> data = new HashMap<>();
for (Map.Entry<String, String> entry : FIELD_PATTERNS.entrySet()) {
String fieldName = entry.getKey();
String pattern = entry.getValue();
String value = parsePattern(pattern);
data.put(fieldName, value != null ? value : "");
}
data.put("bad_user", "false");
data.put("info", "{}");
data.put("cdn_is_working", "true");
return data;
}
private String parsePattern(String pattern) {
Pattern p = Pattern.compile(pattern);
Matcher matcher = p.matcher(document);
return matcher.find() ? matcher.group(1) : null;
}
public String get(String field) {
return parsedData.get(field);
}
public Map<String, String> getAllFields() {
return new HashMap<>(parsedData);
}
public RequestBody toFormBody() {
FormBody.Builder formBuilder = new FormBody.Builder();
for (Map.Entry<String, String> entry : parsedData.entrySet()) {
formBuilder.add(entry.getKey(), entry.getValue());
}
return formBuilder.build();
}
@Override
public String toString() {
return "KodikAPIPayload{" +
"d='" + get("d") + '\'' +
", d_sign='" + get("d_sign") + '\'' +
", pd='" + get("pd") + '\'' +
", pd_sign='" + get("pd_sign") + '\'' +
", ref='" + get("ref") + '\'' +
", ref_sign='" + get("ref_sign") + '\'' +
", type='" + get("type") + '\'' +
", hash='" + get("hash") + '\'' +
", id='" + get("id") + '\'' +
'}';
}
}

View File

@ -0,0 +1,45 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model;
/**
* KodikEpisode
*/
public class KodikEpisode {
private final String value;
private final String id;
private final String hash;
private final String title;
private final boolean otherTranslation;
public KodikEpisode(String value, String id, String hash, String title, boolean otherTranslation) {
this.value = value;
this.id = id;
this.hash = hash;
this.title = title;
this.otherTranslation = otherTranslation;
}
public String getValue() {
return value;
}
public String getId() {
return id;
}
public String getHash() {
return hash;
}
public String getTitle() {
return title;
}
public boolean isOtherTranslation() {
return otherTranslation;
}
@Override
public String toString() {
return title;
}
}

View File

@ -0,0 +1,30 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model;
import java.util.List;
/**
* KodikMetadata
*/
public class KodikMetadata {
private final List<KodikTranslation> translations;
private final List<KodikEpisode> episodes;
private final KodikAPIPayload apiPayload;
public KodikMetadata(List<KodikTranslation> translations, List<KodikEpisode> episodes, KodikAPIPayload apiPayload) {
this.translations = translations;
this.episodes = episodes;
this.apiPayload = apiPayload;
}
public List<KodikTranslation> getTranslations() {
return translations;
}
public List<KodikEpisode> getEpisodes() {
return episodes;
}
public KodikAPIPayload getApiPayload() {
return apiPayload;
}
}

View File

@ -0,0 +1,70 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model;
/**
* KodikTranslation
*/
public class KodikTranslation {
private final String id;
private final String title;
private final String mediaId;
private final String mediaHash;
private final String mediaType;
private final String translationType;
private final int episodeCount;
private final boolean selected;
public KodikTranslation(String id, String title, String mediaId, String mediaHash,
String mediaType, String translationType, int episodeCount, boolean selected) {
this.id = id;
this.title = title;
this.mediaId = mediaId;
this.mediaHash = mediaHash;
this.mediaType = mediaType;
this.translationType = translationType;
this.episodeCount = episodeCount;
this.selected = selected;
}
// Getters
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public String getMediaId() {
return mediaId;
}
public String getMediaHash() {
return mediaHash;
}
public String getMediaType() {
return mediaType;
}
public String getTranslationType() {
return translationType;
}
public int getEpisodeCount() {
return episodeCount;
}
public boolean isSelected() {
return selected;
}
public String buildUrl(int episodeNumber) {
return String.format("https://kodik.info/%s/%s/%s/720p?min_age=16&first_url=false&season=1&episode=%d",
mediaType, mediaId, mediaHash, episodeNumber);
}
@Override
public String toString() {
return String.format("%s (%d)", title, episodeCount);
}
}

View File

@ -1,6 +1,9 @@
package com.backend.extractor.kodik.service.anyame_backend.config;
import com.backend.extractor.kodik.service.anyame_backend.api.KodikPlayerAPI;
import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import retrofit2.Retrofit;
@ -24,4 +27,8 @@ public class APIConfig {
return retrofit.create(KodikPlayerAPI.class);
}
@Bean
public OkHttpClient httpClient() {
return new OkHttpClient();
}
}

View File

@ -0,0 +1,57 @@
package com.backend.extractor.kodik.service.anyame_backend.controller;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.backend.extractor.kodik.service.anyame_backend.api.KodikPlayerAPI;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikMetadata;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikPlayerResponse;
import com.backend.extractor.kodik.service.anyame_backend.component.KodikAPITokenProvider;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikMetadataExtractorService;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikNetworkService;
import retrofit2.Response;
/**
* ExtractController
*/
@RestController
public class ExtractController {
private final KodikMetadataExtractorService metadataExtractorService;
private final KodikNetworkService networkService;
private final KodikPlayerAPI kodikPlayerAPI;
private final KodikAPITokenProvider tokenProvider;
public ExtractController(KodikMetadataExtractorService metadataExtractorService,
KodikNetworkService networkService,
KodikPlayerAPI kodikPlayerAPI,
KodikAPITokenProvider tokenProvider) {
this.metadataExtractorService = metadataExtractorService;
this.networkService = networkService;
this.kodikPlayerAPI = kodikPlayerAPI;
this.tokenProvider = tokenProvider;
}
@GetMapping("/shikimori")
public KodikMetadata shikimori(@RequestParam("id") String shikimoriId) throws IOException {
Response<KodikPlayerResponse> response = kodikPlayerAPI
.getShikimoriPlayer(shikimoriId, tokenProvider.getKodikToken()).execute();
if (!response.isSuccessful()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "cannot find player, check id validity");
}
KodikPlayerResponse responseResult = response.body();
return extractMetadata(responseResult);
}
private KodikMetadata extractMetadata(KodikPlayerResponse response) throws IOException {
String rawLink = response.getLink();
String rawPage = networkService.fetchPage(rawLink);
return metadataExtractorService.parseMetadata(rawPage);
}
}

View File

@ -0,0 +1,10 @@
package com.backend.extractor.kodik.service.anyame_backend.exception;
/**
* KodikAPINotFoundException
*/
public class KodikAPINotFoundException extends Exception {
public KodikAPINotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,14 @@
package com.backend.extractor.kodik.service.anyame_backend.exception;
/**
* KodikExtractionException
*/
public class KodikExtractionException extends Exception {
public KodikExtractionException(String message, Throwable cause) {
super(message, cause);
}
public KodikExtractionException(String message) {
super(message);
}
}

View File

@ -0,0 +1,10 @@
package com.backend.extractor.kodik.service.anyame_backend.exception;
/**
* KodikPlayerNotFoundException
*/
public class KodikPlayerNotFoundException extends Exception {
public KodikPlayerNotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,11 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import org.springframework.stereotype.Service;
/**
* KodikLinkExtrtactService
*/
@Service
public class KodikLinkExtrtactService {
}

View File

@ -0,0 +1,77 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import java.util.ArrayList;
import java.util.List;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikAPIPayload;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikEpisode;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikMetadata;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikTranslation;
/**
* KodikMetadataExtractorService
*/
@Service
public class KodikMetadataExtractorService {
public KodikMetadata parseMetadata(String html) {
Document doc = Jsoup.parse(html);
List<KodikTranslation> translations = parseTranslations(doc);
List<KodikEpisode> episodes = parseEpisodes(doc);
KodikAPIPayload apiPayload = new KodikAPIPayload(html);
return new KodikMetadata(translations, episodes, apiPayload);
}
private List<KodikTranslation> parseTranslations(Document doc) {
List<KodikTranslation> translations = new ArrayList<>();
Elements options = doc.select(".serial-translations-box select option");
for (Element option : options) {
String id = option.attr("value");
String title = option.attr("data-title");
String mediaId = option.attr("data-media-id");
String mediaHash = option.attr("data-media-hash");
String mediaType = option.attr("data-media-type");
String translationType = option.attr("data-translation-type");
int episodeCount = parseIntSafely(option.attr("data-episode-count"));
boolean selected = option.hasAttr("selected");
translations.add(new KodikTranslation(id, title, mediaId, mediaHash,
mediaType, translationType, episodeCount, selected));
}
return translations;
}
private List<KodikEpisode> parseEpisodes(Document doc) {
List<KodikEpisode> episodes = new ArrayList<>();
Elements options = doc.select(".serial-series-box select option");
for (Element option : options) {
String value = option.attr("value");
String id = option.attr("data-id");
String hash = option.attr("data-hash");
String title = option.attr("data-title");
boolean otherTranslation = "true".equals(option.attr("data-other-translation"));
episodes.add(new KodikEpisode(value, id, hash, title, otherTranslation));
}
return episodes;
}
private static int parseIntSafely(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@ -0,0 +1,73 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import org.springframework.stereotype.Service;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* KodikNetworkService
*/
@Service
public class KodikNetworkService {
private static final String DEFAULT_PROTOCOL = "https:";
private final OkHttpClient httpClient;
public KodikNetworkService(OkHttpClient httpClient) {
this.httpClient = httpClient;
}
public String fetchPage(String url) throws IOException {
Request request = new Request.Builder()
.url(normalizeUrl(url))
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("Failed to fetch page: " + response);
}
return response.body().string();
}
}
public String postFormData(String url, RequestBody body, String referer) throws IOException {
String baseUrl = getBaseUrl(url);
Request request = new Request.Builder()
.url(url)
.post(body)
.addHeader("Origin", baseUrl)
.addHeader("Referer", referer)
.addHeader("Accept", "application/json, text/javascript, */*; q=0.01")
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("API request failed: " + response);
}
return response.body().string();
}
}
private String normalizeUrl(String url) {
if (url.startsWith("//")) {
return DEFAULT_PROTOCOL + url;
}
return url;
}
private String getBaseUrl(String url) {
try {
URL urlObj = new URI(url).toURL();
return urlObj.getProtocol() + "://" + urlObj.getHost();
} catch (Exception e) {
throw new RuntimeException("Invalid URL: " + url, e);
}
}
}