13 Commits

12 changed files with 192 additions and 60 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
KODIK_TOKEN=YOUR_KODIK_TOKEN
EUREKA_SCHEMA=http
EUREKA_HOST=anyame-vue-bff:8080

View File

@ -2,9 +2,9 @@
FROM maven:3.9.6-eclipse-temurin-21 AS builder FROM maven:3.9.6-eclipse-temurin-21 AS builder
WORKDIR /workspace WORKDIR /workspace
COPY pom.xml . COPY pom.xml .
RUN mvn dependency:go-offline -B RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B
COPY src ./src COPY src ./src
RUN mvn clean package -DskipTests RUN --mount=type=cache,target=/root/.m2 mvn clean package -DskipTests
# Create optimized runtime # Create optimized runtime
FROM eclipse-temurin:21 AS app-build FROM eclipse-temurin:21 AS app-build

View File

@ -1,11 +1,15 @@
services: services:
extractor: kodik-extractor:
image: anyame-extractor:latest image: anyame-kodik-extractor:latest
ports: ports:
- 8081:8080 - 8081:8080
env_file: .env env_file: .env
networks: networks:
- anyame - anyame-shared
- elk-network
networks: networks:
anyame: anyame-shared:
driver: bridge external: true
elk-network:
external: true

18
pom.xml
View File

@ -6,13 +6,13 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version> <version>3.5.5</version>
<relativePath /> <!-- lookup parent from repository --> <relativePath /> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.backend.extractor.kodik.service</groupId> <groupId>com.backend.extractor.kodik.service</groupId>
<artifactId>anyame-backend</artifactId> <artifactId>anyame-kodik-extractor-backend</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>anyame-backend</name> <name>anyame-kodik-extractor-backend</name>
<description>Kodik video URL extractor for anyame</description> <description>Kodik video URL extractor for anyame</description>
<url /> <url />
<licenses> <licenses>
@ -34,6 +34,8 @@
<jsoup.version>1.20.1</jsoup.version> <jsoup.version>1.20.1</jsoup.version>
<spring-dotenv.version>4.0.0</spring-dotenv.version> <spring-dotenv.version>4.0.0</spring-dotenv.version>
<springdoc-openapi-starter.version>2.8.9</springdoc-openapi-starter.version> <springdoc-openapi-starter.version>2.8.9</springdoc-openapi-starter.version>
<spring-eureka-client.version>4.3.0</spring-eureka-client.version>
<logstash-logback-encoder.version>8.1</logstash-logback-encoder.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
@ -66,6 +68,16 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc-openapi-starter.version}</version> <version>${springdoc-openapi-starter.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>${spring-eureka-client.version}</version>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>${logstash-logback-encoder.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@ -0,0 +1,21 @@
package com.backend.extractor.kodik.service.anyame_backend.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager(
"kodikIDPlayerLink",
"shikimoriIDPlayerLink",
"kinopoiskIDPlayerLink",
"imdbIDPlayerLink",
"kodikPage");
}
}

View File

@ -11,66 +11,70 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; 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.model.KodikPlayerResponse;
import com.backend.extractor.kodik.service.anyame_backend.component.KodikAPITokenProvider;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikExtractionException; import com.backend.extractor.kodik.service.anyame_backend.exception.KodikExtractionException;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikExtractService; import com.backend.extractor.kodik.service.anyame_backend.service.KodikExtractService;
import com.backend.extractor.kodik.service.anyame_backend.service.KodikPlayerLinkResolveService;
import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMetadata; import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMetadata;
import retrofit2.Response;
@RestController @RestController
@RequestMapping("/metadata") @RequestMapping("/metadata")
public class MetadataController { public class MetadataController {
Logger logger = LoggerFactory.getLogger(MetadataController.class); Logger logger = LoggerFactory.getLogger(MetadataController.class);
private final KodikPlayerAPI kodikPlayerAPI;
private final KodikAPITokenProvider tokenProvider;
private final KodikExtractService extractService; private final KodikExtractService extractService;
private final KodikPlayerLinkResolveService linkResolveService;
public MetadataController( public MetadataController(KodikExtractService extractService, KodikPlayerLinkResolveService linkResolveService) {
KodikPlayerAPI kodikPlayerAPI,
KodikAPITokenProvider tokenProvider, KodikExtractService extractService) {
this.kodikPlayerAPI = kodikPlayerAPI;
this.tokenProvider = tokenProvider;
this.extractService = extractService; this.extractService = extractService;
this.linkResolveService = linkResolveService;
} }
@GetMapping("/shikimori") @GetMapping("/shikimori")
public KodikMetadata shikimori(@RequestParam("id") String shikimoriId) { public KodikMetadata shikimori(@RequestParam("id") String shikimoriId) {
try { return findMetadata(() -> {
Response<KodikPlayerResponse> response = kodikPlayerAPI String url = linkResolveService.resolveShikimoriLink(shikimoriId);
.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();
return extractService.getMetadata(url); return extractService.getMetadata(url);
} catch (KodikExtractionException e) { });
logger.error("cannot retrieve metadata", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot retrieve metadata", e);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "i/o error");
}
} }
@GetMapping("/kodik") @GetMapping("/kodik")
public KodikMetadata kodik(@RequestParam("id") String kodikId) { public KodikMetadata kodik(@RequestParam("id") String kodikId) {
try { return findMetadata(() -> {
Response<KodikPlayerResponse> response = kodikPlayerAPI String url = linkResolveService.resolveKodikLink(kodikId);
.getKodikIDPlayer(kodikId, 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();
return extractService.getMetadata(url); return extractService.getMetadata(url);
});
}
@GetMapping("/kinopoisk")
public KodikMetadata kinopoisk(@RequestParam("id") String kinopoiskId) {
return findMetadata(() -> {
String url = linkResolveService.resolveKinopoiskLink(kinopoiskId);
return extractService.getMetadata(url);
});
}
@GetMapping("/imdb")
public KodikMetadata imdb(@RequestParam("id") String imdbId) {
return findMetadata(() -> {
String url = linkResolveService.resolveIMDBLink(imdbId);
return extractService.getMetadata(url);
});
}
private KodikMetadata findMetadata(SupplierWithException<KodikMetadata> supplier) {
try {
return supplier.get();
} catch (KodikExtractionException e) { } catch (KodikExtractionException e) {
logger.error("cannot retrieve metadata", e); logger.error("cannot retrieve metadata", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot retrieve metadata", e); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "cannot retrieve metadata", e);
} catch (IOException e) { } catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "i/o error"); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "i/o error");
} catch (Exception e) {
logger.error("Unknown error", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "unknown error");
} }
} }
private static interface SupplierWithException<T> {
T get() throws Exception;
}
} }

View File

@ -4,7 +4,6 @@ import java.io.IOException;
import org.springframework.stereotype.Service; 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.api.model.KodikVideoLinks;
import com.backend.extractor.kodik.service.anyame_backend.exception.KodikAPINotFoundException; 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.KodikExtractionException;

View File

@ -10,6 +10,8 @@ import org.jsoup.Jsoup;
import org.jsoup.nodes.Document; import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.jsoup.select.Elements; import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikEpisode; import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikEpisode;
@ -22,6 +24,7 @@ import com.backend.extractor.kodik.service.anyame_backend.service.model.KodikMet
*/ */
@Service @Service
public class KodikHtmlParserService { public class KodikHtmlParserService {
private static final Logger log = LoggerFactory.getLogger(KodikHtmlParserService.class);
private static final Pattern VIDEO_INFO_TYPE = Pattern.compile("https://kodik\\.info/([^\\s/]+)"); private static final Pattern VIDEO_INFO_TYPE = Pattern.compile("https://kodik\\.info/([^\\s/]+)");
public KodikMetadata parseMetadata(String html) { public KodikMetadata parseMetadata(String html) {
@ -37,7 +40,10 @@ public class KodikHtmlParserService {
private List<KodikTranslation> parseTranslations(Document doc) { private List<KodikTranslation> parseTranslations(Document doc) {
List<KodikTranslation> translations = new ArrayList<>(); List<KodikTranslation> translations = new ArrayList<>();
Elements translationOptions = doc.select(".serial-translations-box select option"); String possibleMediaType = findVideoType(doc.html()).orElse("serial");
boolean isMovie = possibleMediaType.equals("video");
String videoType = isMovie ? "movie" : possibleMediaType;
Elements translationOptions = doc.select(String.format(".%s-translations-box select option", videoType));
for (Element option : translationOptions) { for (Element option : translationOptions) {
String id = option.attr("value"); String id = option.attr("value");
@ -46,24 +52,28 @@ public class KodikHtmlParserService {
String mediaHash = option.attr("data-media-hash"); String mediaHash = option.attr("data-media-hash");
String mediaType = option.attr("data-media-type"); String mediaType = option.attr("data-media-type");
String translationType = option.attr("data-translation-type"); String translationType = option.attr("data-translation-type");
int episodeCount = parseIntSafely(option.attr("data-episode-count")); int episodeCount = isMovie ? 1 : parseIntSafely(option.attr("data-episode-count"));
translations.add(new KodikTranslation(id, title, mediaId, mediaHash, translations.add(new KodikTranslation(id, title, mediaId, mediaHash,
mediaType, translationType, episodeCount)); mediaType, translationType, episodeCount));
} }
Elements seasonOptions = doc.select(".serial-seasons-box select option"); Elements seasonOptions = doc.select(String.format(".%s-seasons-box select option", videoType));
Elements seriesOptions = doc.select(".serial-series-box select option"); Elements seriesOptions = doc.select(String.format(".%s-series-box select option", videoType));
for (Element option : seasonOptions) { if (translations.isEmpty()) {
if (!option.hasAttr("selected")) { for (Element option : seasonOptions) {
continue; if (!option.hasAttr("selected")) {
continue;
}
String id = option.attr("value");
String title = option.attr("data-translation-title");
String mediaId = option.attr("data-serial-id");
String mediaHash = option.attr("data-serial-hash");
translations
.add(new KodikTranslation(id, title, mediaId, mediaHash, possibleMediaType, "",
seriesOptions.size()));
} }
String id = option.attr("value");
String title = option.attr("data-translation-title");
String mediaId = option.attr("data-serial-id");
String mediaHash = option.attr("data-serial-hash");
String mediaType = findVideoType(doc.html()).orElse("serial");
translations.add(new KodikTranslation(id, title, mediaId, mediaHash, mediaType, "", seriesOptions.size()));
} }
return translations; return translations;
@ -93,8 +103,12 @@ public class KodikHtmlParserService {
private static int parseIntSafely(String value) { private static int parseIntSafely(String value) {
try { try {
if (value.contains("~")) {
return Integer.parseInt(value.split("~")[1]);
}
return Integer.parseInt(value); return Integer.parseInt(value);
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
log.error(String.format("invalid number format %s", value), e);
return 0; return 0;
} }
} }

View File

@ -4,6 +4,7 @@ import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikVideoLinks; import com.backend.extractor.kodik.service.anyame_backend.api.model.KodikVideoLinks;
@ -28,6 +29,7 @@ public class KodikNetworkService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@Cacheable("kodikPage")
public String fetchPage(String url) throws IOException { public String fetchPage(String url) throws IOException {
Request request = new Request.Builder() Request request = new Request.Builder()
.url(normalizeUrl(url)) .url(normalizeUrl(url))

View File

@ -0,0 +1,61 @@
package com.backend.extractor.kodik.service.anyame_backend.service;
import java.io.IOException;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
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.KodikPlayerResponse;
import com.backend.extractor.kodik.service.anyame_backend.component.KodikAPITokenProvider;
import retrofit2.Response;
@Service
public class KodikPlayerLinkResolveService {
private final KodikPlayerAPI kodikPlayerAPI;
private final KodikAPITokenProvider tokenProvider;
public KodikPlayerLinkResolveService(KodikPlayerAPI kodikPlayerAPI, KodikAPITokenProvider tokenProvider) {
this.kodikPlayerAPI = kodikPlayerAPI;
this.tokenProvider = tokenProvider;
}
@Cacheable("kodikIDPlayerLink")
public String resolveKodikLink(String kodikID) throws IOException {
Response<KodikPlayerResponse> response = kodikPlayerAPI.getKodikIDPlayer(kodikID, tokenProvider.getKodikToken())
.execute();
return retrieveLink(response);
}
@Cacheable("shikimoriIDPlayerLink")
public String resolveShikimoriLink(String shikimoriID) throws IOException {
Response<KodikPlayerResponse> response = kodikPlayerAPI
.getShikimoriPlayer(shikimoriID, tokenProvider.getKodikToken()).execute();
return retrieveLink(response);
}
@Cacheable("kinopoiskIDPlayerLink")
public String resolveKinopoiskLink(String kinopoiskID) throws IOException {
Response<KodikPlayerResponse> response = kodikPlayerAPI
.getKinopoiskPlayer(kinopoiskID, tokenProvider.getKodikToken()).execute();
return retrieveLink(response);
}
@Cacheable("imdbIDPlayerLink")
public String resolveIMDBLink(String imdbID) throws IOException {
Response<KodikPlayerResponse> response = kodikPlayerAPI.getIMDBPlayer(imdbID, tokenProvider.getKodikToken())
.execute();
return retrieveLink(response);
}
private String retrieveLink(Response<KodikPlayerResponse> response) {
if (response.isSuccessful()) {
return response.body().getLink();
} else {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "cannot find player, check id validity");
}
}
}

View File

@ -1,3 +1,4 @@
spring.application.name=anyame-backend spring.application.name=anyame-kodik-extractor-backend
server.error.include-message=always
kodik.token=${KODIK_TOKEN} kodik.token=${KODIK_TOKEN}
eureka.client.serviceUrl.defaultZone: ${EUREKA_SCHEMA}://${EUREKA_HOST}/eureka/

View File

@ -0,0 +1,11 @@
<configuration>
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>logstash:5044</destination>
<encoder class="net.logstash.logback.encoder.LogstashEncoder" />
</appender>
<root level="INFO">
<appender-ref ref="LOGSTASH" />
</root>
</configuration>