Video link retrieve support with controller

This commit is contained in:
2025-06-05 14:55:58 +05:00
parent 833c3d9fd4
commit 4611b0caee
13 changed files with 306 additions and 60 deletions

View File

@ -1,8 +1,11 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model; package com.backend.extractor.kodik.service.anyame_backend.api.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/** /**
* KodikTranslation * KodikTranslation
*/ */
@JsonIgnoreProperties(ignoreUnknown = true)
public class KodikTranslation { public class KodikTranslation {
private final String id; private final String id;
private final String title; private final String title;
@ -11,10 +14,9 @@ public class KodikTranslation {
private final String mediaType; private final String mediaType;
private final String translationType; private final String translationType;
private final int episodeCount; private final int episodeCount;
private final boolean selected;
public KodikTranslation(String id, String title, String mediaId, String mediaHash, public KodikTranslation(String id, String title, String mediaId, String mediaHash,
String mediaType, String translationType, int episodeCount, boolean selected) { String mediaType, String translationType, int episodeCount) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.mediaId = mediaId; this.mediaId = mediaId;
@ -22,10 +24,8 @@ public class KodikTranslation {
this.mediaType = mediaType; this.mediaType = mediaType;
this.translationType = translationType; this.translationType = translationType;
this.episodeCount = episodeCount; this.episodeCount = episodeCount;
this.selected = selected;
} }
// Getters
public String getId() { public String getId() {
return id; return id;
} }
@ -54,11 +54,7 @@ public class KodikTranslation {
return episodeCount; return episodeCount;
} }
public boolean isSelected() { public String buildUrl(String quality, int episodeNumber) {
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", return String.format("https://kodik.info/%s/%s/%s/720p?min_age=16&first_url=false&season=1&episode=%d",
mediaType, mediaId, mediaHash, episodeNumber); mediaType, mediaId, mediaHash, episodeNumber);
} }

View File

@ -0,0 +1,56 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model;
import java.util.List;
import java.util.Map;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikDecryptionException;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikURLDecoderService;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* KodikVideoLinks
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class KodikVideoLinks {
private final Map<String, List<Link>> links;
public KodikVideoLinks(Map<String, List<Link>> links) {
this.links = links;
}
public Map<String, List<Link>> getLinks() {
return links;
}
public void decodeLinks(KodikURLDecoderService decoderService) throws KodikDecryptionException {
for (List<Link> links : links.values()) {
for (Link link : links) {
link.decodeSrc(decoderService);
}
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Link {
private final String type;
private String src;
public Link(String src, String type) {
this.src = src;
this.type = type;
}
public String getSrc() {
return src;
}
public String getType() {
return type;
}
public void decodeSrc(KodikURLDecoderService decoderService) throws KodikDecryptionException {
this.src = decoderService.getLinkData(this.src);
}
}
}

View File

@ -9,11 +9,16 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; 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.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.api.model.KodikPlayerResponse;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikVideoLinks;
import com.backend.extractor.kodik.service.anyame_backend.component.KodikAPITokenProvider; 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.exception.KodikAPINotFoundException;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikNetworkService; import com.backend.extractor.kodik.service.anyame_backend.exception.KodikDecryptionException;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikExtractionException;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikPlayerNotFoundException;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikExtractService;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikURLDecoderService;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMetadata;
import retrofit2.Response; import retrofit2.Response;
@ -23,35 +28,64 @@ import retrofit2.Response;
@RestController @RestController
public class ExtractController { public class ExtractController {
private final KodikMetadataExtractorService metadataExtractorService;
private final KodikNetworkService networkService;
private final KodikPlayerAPI kodikPlayerAPI; private final KodikPlayerAPI kodikPlayerAPI;
private final KodikAPITokenProvider tokenProvider; private final KodikAPITokenProvider tokenProvider;
private final KodikExtractService extractService;
private final KodikURLDecoderService decoderService;
public ExtractController(KodikMetadataExtractorService metadataExtractorService, public ExtractController(
KodikNetworkService networkService,
KodikPlayerAPI kodikPlayerAPI, KodikPlayerAPI kodikPlayerAPI,
KodikAPITokenProvider tokenProvider) { KodikAPITokenProvider tokenProvider, KodikExtractService extractService,
this.metadataExtractorService = metadataExtractorService; KodikURLDecoderService decoderService) {
this.networkService = networkService;
this.kodikPlayerAPI = kodikPlayerAPI; this.kodikPlayerAPI = kodikPlayerAPI;
this.tokenProvider = tokenProvider; this.tokenProvider = tokenProvider;
this.extractService = extractService;
this.decoderService = decoderService;
} }
@GetMapping("/shikimori") @GetMapping("/shikimori")
public KodikMetadata shikimori(@RequestParam("id") String shikimoriId) throws IOException { public KodikMetadata shikimori(@RequestParam("id") String shikimoriId) {
try {
Response<KodikPlayerResponse> response = kodikPlayerAPI Response<KodikPlayerResponse> response = kodikPlayerAPI
.getShikimoriPlayer(shikimoriId, tokenProvider.getKodikToken()).execute(); .getShikimoriPlayer(shikimoriId, tokenProvider.getKodikToken()).execute();
if (!response.isSuccessful()) { if (!response.isSuccessful()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "cannot find player, check id validity"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "cannot find player, check id validity");
} }
KodikPlayerResponse responseResult = response.body(); KodikPlayerResponse responseResult = response.body();
return extractMetadata(responseResult); String url = responseResult.getLink();
return extractService.getMetadata(url);
} catch (KodikExtractionException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot retrieve metadat", e);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "i/o error");
}
} }
private KodikMetadata extractMetadata(KodikPlayerResponse response) throws IOException { @GetMapping("/shikimori/video")
String rawLink = response.getLink(); public KodikVideoLinks shikimoriVideo(@RequestParam("id") String shikimoriId) {
String rawPage = networkService.fetchPage(rawLink); try {
return metadataExtractorService.parseMetadata(rawPage); 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();
String url = responseResult.getLink();
KodikVideoLinks links = extractService.retrieveVideoLinks(url);
links.decodeLinks(decoderService);
return links;
} catch (KodikExtractionException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot retrieve metadat", e);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "i/o error");
} catch (KodikPlayerNotFoundException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "player not found");
} catch (KodikAPINotFoundException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "api endpoint not found");
} catch (KodikDecryptionException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot decode links");
}
}
} }

View File

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

View File

@ -0,0 +1,73 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import java.io.IOException;
import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikTranslation;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikVideoLinks;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikAPINotFoundException;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikExtractionException;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikPlayerNotFoundException;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikAPIPayload;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMetadata;
/**
* KodikExtractService
*/
@Service
public class KodikExtractService {
private final KodikNetworkService networkService;
private final KodikPlayerEndpointService endpointService;
private final KodikHtmlParserService htmlParserService;
public KodikExtractService(KodikPlayerEndpointService endpointService, KodikNetworkService networkService,
KodikHtmlParserService htmlParserService) {
this.networkService = networkService;
this.endpointService = endpointService;
this.htmlParserService = htmlParserService;
}
public KodikMetadata getMetadata(String metadataURL) throws KodikExtractionException {
try {
String html = networkService.fetchPage(metadataURL);
return htmlParserService.parseMetadata(html);
} catch (Exception e) {
throw new KodikExtractionException("Failed to extract metadata", e);
}
}
public KodikVideoLinks retrieveVideoLinks(KodikTranslation translation, int episode)
throws KodikExtractionException, IOException, KodikPlayerNotFoundException, KodikAPINotFoundException {
String episodeUrl = translation.buildUrl(episode);
return retrieveVideoLinks(episodeUrl);
}
public KodikVideoLinks retrieveVideoLinks(String episodeUrl)
throws KodikExtractionException, IOException, KodikPlayerNotFoundException, KodikAPINotFoundException {
KodikMetadata metadata = getMetadata(episodeUrl);
String playerEndpoint = endpointService.retrieveEndpoint(episodeUrl);
return makeApiRequestWithFallback(episodeUrl, playerEndpoint, metadata.getApiPayload());
}
private KodikVideoLinks makeApiRequestWithFallback(String episodeUrl, String playerEndpoint,
KodikAPIPayload apiPayload)
throws IOException, KodikPlayerNotFoundException, KodikAPINotFoundException {
String baseUrl = networkService.getBaseUrl(episodeUrl);
String apiUrl = baseUrl + playerEndpoint;
try {
return networkService.postFormData(apiUrl, apiPayload.toFormBody(), episodeUrl);
} catch (IOException e) {
// Update endpoint, then request again
if (!endpointService.hasUpdateAttempted()) {
String newEndpoint = endpointService.updateEndpoint(episodeUrl);
String newApiUrl = baseUrl + newEndpoint;
return networkService.postFormData(newApiUrl, apiPayload.toFormBody(), episodeUrl);
}
throw e;
}
}
}

View File

@ -9,16 +9,17 @@ import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.springframework.stereotype.Service; 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.KodikEpisode;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikMetadata;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikTranslation; import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikTranslation;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikAPIPayload;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMetadata;
/** /**
* KodikMetadataExtractorService * KodikMetadataExtractorService
*/ */
@Service @Service
public class KodikMetadataExtractorService { public class KodikHtmlParserService {
public KodikMetadata parseMetadata(String html) { public KodikMetadata parseMetadata(String html) {
Document doc = Jsoup.parse(html); Document doc = Jsoup.parse(html);

View File

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

View File

@ -6,6 +6,9 @@ import java.net.URL;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikVideoLinks;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.RequestBody; import okhttp3.RequestBody;
@ -18,9 +21,11 @@ import okhttp3.Response;
public class KodikNetworkService { public class KodikNetworkService {
private static final String DEFAULT_PROTOCOL = "https:"; private static final String DEFAULT_PROTOCOL = "https:";
private final OkHttpClient httpClient; private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
public KodikNetworkService(OkHttpClient httpClient) { public KodikNetworkService(OkHttpClient httpClient, ObjectMapper objectMapper) {
this.httpClient = httpClient; this.httpClient = httpClient;
this.objectMapper = objectMapper;
} }
public String fetchPage(String url) throws IOException { public String fetchPage(String url) throws IOException {
@ -36,7 +41,7 @@ public class KodikNetworkService {
} }
} }
public String postFormData(String url, RequestBody body, String referer) throws IOException { public KodikVideoLinks postFormData(String url, RequestBody body, String referer) throws IOException {
String baseUrl = getBaseUrl(url); String baseUrl = getBaseUrl(url);
Request request = new Request.Builder() Request request = new Request.Builder()
@ -51,7 +56,8 @@ public class KodikNetworkService {
if (!response.isSuccessful()) { if (!response.isSuccessful()) {
throw new IOException("API request failed: " + response); throw new IOException("API request failed: " + response);
} }
return response.body().string(); String rawResult = response.body().string();
return objectMapper.readValue(rawResult, KodikVideoLinks.class);
} }
} }
@ -62,8 +68,9 @@ public class KodikNetworkService {
return url; return url;
} }
private String getBaseUrl(String url) { public String getBaseUrl(String url) {
try { try {
url = normalizeUrl(url);
URL urlObj = new URI(url).toURL(); URL urlObj = new URI(url).toURL();
return urlObj.getProtocol() + "://" + urlObj.getHost(); return urlObj.getProtocol() + "://" + urlObj.getHost();
} catch (Exception e) { } catch (Exception e) {

View File

@ -0,0 +1,80 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import java.io.IOException;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikAPINotFoundException;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikPlayerNotFoundException;
/**
* KodikPlayerEndpointService
*/
@Service
public class KodikPlayerEndpointService {
private static final Pattern PLAYER_JS_PATTERN = Pattern
.compile("<script\\s*type=\"text/javascript\"\\s*src=\"(/assets/js/app\\..*?)\">");
private static final Pattern AJAX_URL_PATTERN = Pattern
.compile("\\$\\.ajax\\([^>]+,url:\\s*atob\\([\"']([\\w=]+)[\"']\\)");
private final KodikNetworkService networkService;
private String cachedEndpoint;
private boolean updateAttempted = false;
public KodikPlayerEndpointService(KodikNetworkService networkService) {
this.networkService = networkService;
}
public String retrieveEndpoint(String metadataUrl)
throws IOException, KodikPlayerNotFoundException, KodikAPINotFoundException {
if (cachedEndpoint != null) {
return cachedEndpoint;
}
return updateEndpoint(metadataUrl);
}
public String updateEndpoint(String metadataUrl)
throws IOException, KodikPlayerNotFoundException, KodikAPINotFoundException {
String html = networkService.fetchPage(metadataUrl);
String playerJsPath = extractPlayerJsPath(html, metadataUrl);
cachedEndpoint = extractEndpointFromJs(metadataUrl, playerJsPath);
updateAttempted = true;
return cachedEndpoint;
}
public boolean hasUpdateAttempted() {
return updateAttempted;
}
public void clearCache() {
cachedEndpoint = null;
updateAttempted = false;
}
private String extractPlayerJsPath(String html, String metadataUrl) throws KodikPlayerNotFoundException {
Matcher matcher = PLAYER_JS_PATTERN.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
throw new KodikPlayerNotFoundException("Player JS asset not found in: " + metadataUrl);
}
private String extractEndpointFromJs(String metadataUrl, String playerJsPath)
throws IOException, KodikAPINotFoundException {
String baseUrl = networkService.getBaseUrl(metadataUrl);
String playerJsUrl = baseUrl + playerJsPath;
String jsContent = networkService.fetchPage(playerJsUrl);
Matcher matcher = AJAX_URL_PATTERN.matcher(jsContent);
if (matcher.find()) {
String base64Endpoint = matcher.group(1);
byte[] decodedBytes = Base64.getDecoder().decode(base64Endpoint);
return new String(decodedBytes);
}
throw new KodikAPINotFoundException("API endpoint not found in: " + playerJsUrl);
}
}

View File

@ -5,6 +5,8 @@ import java.util.Base64;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikDecryptionException;
/** /**
* KodikURLDecoder * KodikURLDecoder
*/ */
@ -34,8 +36,10 @@ public class KodikURLDecoderService {
/** /**
* Try to decrypt the input string by testing all possible ROT cipher rotations * Try to decrypt the input string by testing all possible ROT cipher rotations
*
* @throws KodikDecryptionException
*/ */
private String tryToDecrypt(String input) { private String tryToDecrypt(String input) throws KodikDecryptionException {
for (int rotation = 0; rotation < 26; rotation++) { for (int rotation = 0; rotation < 26; rotation++) {
StringBuilder rotatedString = new StringBuilder(); StringBuilder rotatedString = new StringBuilder();
for (char ch : input.toCharArray()) { for (char ch : input.toCharArray()) {
@ -69,7 +73,7 @@ public class KodikURLDecoderService {
* @return The decoded video URL * @return The decoded video URL
* @throws KodikDecryptionException if decryption fails * @throws KodikDecryptionException if decryption fails
*/ */
public String getLinkData(String videoUrl) { public String getLinkData(String videoUrl) throws KodikDecryptionException {
if (videoUrl == null || videoUrl.trim().isEmpty()) { if (videoUrl == null || videoUrl.trim().isEmpty()) {
throw new IllegalArgumentException("Video URL cannot be null or empty"); throw new IllegalArgumentException("Video URL cannot be null or empty");
} }
@ -77,12 +81,4 @@ public class KodikURLDecoderService {
return tryToDecrypt(videoUrl); return tryToDecrypt(videoUrl);
} }
/**
* Custom exception for Kodik decryption failures
*/
public static class KodikDecryptionException extends RuntimeException {
public KodikDecryptionException(String message) {
super(message);
}
}
} }

View File

@ -1,4 +1,4 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model; package com.backend.extractor.kodik.service.anyame_backend.service.model;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;

View File

@ -1,7 +1,10 @@
package com.backend.extractor.kodik.service.anyame_backend.api.model; package com.backend.extractor.kodik.service.anyame_backend.service.model;
import java.util.List; import java.util.List;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikEpisode;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikTranslation;
/** /**
* KodikMetadata * KodikMetadata
*/ */

View File

@ -1 +1,2 @@
spring.application.name=anyame-backend spring.application.name=anyame-backend
server.error.include-message=always