Skip to content

Commit

Permalink
Merge pull request #308 from Shikkanime/dev
Browse files Browse the repository at this point in the history
Implement Calendar.kt
  • Loading branch information
Ziedelth authored Mar 25, 2024
2 parents 4095bd5 + bb0cd18 commit abaa7e9
Show file tree
Hide file tree
Showing 11 changed files with 349 additions and 9 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ sonar {
properties {
property("sonar.projectKey", "core")
property("sonar.projectName", "core")
property("sonar.exclusions", "**/fr/shikkanime/socialnetworks/**")
property("sonar.exclusions", "**/fr/shikkanime/socialnetworks/**,**/fr/shikkanime/jobs/FetchCalendarJob.kt")
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/fr/shikkanime/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ fun initAll(adminPassword: AtomicReference<String>?, port: Int = 37100, wait: Bo
JobManager.scheduleJob("0 0 * * * ?", FetchDeprecatedEpisodeJob::class.java)
// Every day at midnight
JobManager.scheduleJob("0 0 0 * * ?", DeleteOldMetricsJob::class.java)
// Every day at 9am
JobManager.scheduleJob("0 0 9 * * ?", FetchCalendarJob::class.java)
JobManager.start()

logger.info("Starting server...")
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/fr/shikkanime/dtos/CalendarEpisodeDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package fr.shikkanime.dtos

data class CalendarEpisodeDto(
val anime: String,
val season: Int = 1,
val episode: String,
val platform: String
)
271 changes: 271 additions & 0 deletions src/main/kotlin/fr/shikkanime/jobs/FetchCalendarJob.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package fr.shikkanime.jobs

import com.google.inject.Inject
import fr.shikkanime.dtos.CalendarEpisodeDto
import fr.shikkanime.entities.enums.Platform
import fr.shikkanime.services.ImageService
import fr.shikkanime.services.ImageService.drawStringRect
import fr.shikkanime.services.ImageService.setRenderingHints
import fr.shikkanime.services.caches.LanguageCacheService
import fr.shikkanime.utils.*
import fr.shikkanime.utils.StringUtils.capitalizeWords
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.runBlocking
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.awt.Color
import java.awt.Font
import java.awt.Graphics2D
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.logging.Level
import javax.imageio.ImageIO

class FetchCalendarJob : AbstractJob {
private val logger = LoggerFactory.getLogger(javaClass)

@Inject
private lateinit var languageCacheService: LanguageCacheService

override fun run() {
runBlocking {
HttpRequest().use { httpRequest ->
val response = httpRequest.get("https://anime.icotaku.com/calendrier_diffusion.html")

if (response.status != HttpStatusCode.OK) {
logger.log(Level.SEVERE, "Error: ${response.status}")
return@use
}

val body = Jsoup.parse(response.bodyAsText())
val todayElement = requireNotNull(body.select(".calendrier_diffusion")[0]) { "Today not found" }
val elements = todayElement.select("tr").toMutableList()
elements.removeAt(0)

val episodes = getEpisodes(elements, httpRequest)

val backgroundImage = getBackgroundImage() ?: return@use
val calendarImage = BufferedImage(backgroundImage.width, 800, BufferedImage.TYPE_INT_ARGB)
val graphics = calendarImage.createGraphics()
graphics.setRenderingHints()
graphics.drawImage(backgroundImage, 0, 0, null)
graphics.color = Color(0xFFFFFF)
graphics.font = graphics.font.deriveFont(24f)
graphics.font = graphics.font.deriveFont(graphics.font.style or Font.BOLD)

val date = ZonedDateTime.now().format(DateTimeFormatter.ofPattern("EEEE dd MMMM", Locale.FRENCH))

val dateString = "Les animés du $date".uppercase()
val dateWidth = graphics.fontMetrics.stringWidth(dateString)
graphics.drawString(dateString, 25, 60)
graphics.fillRect(25, 75, dateWidth, 2)
graphics.drawStringRect("CALENDRIER", 800, 25, 20, 20, backgroundColor = Color(0x2F2F2F))
val episodesString = drawEpisodes(episodes, graphics, 150, 800)
graphics.dispose()

val calendarImageByteArray = try {
val byteArrayOutputStream = ByteArrayOutputStream()
ImageIO.write(calendarImage, "png", byteArrayOutputStream)
byteArrayOutputStream.toByteArray()
} catch (e: Exception) {
logger.log(Level.SEVERE, "Error while converting calendar image for social networks", e)
return@use
}

val message = "\uD83C\uDF05 Voici les sorties animés du ${date.lowercase()} : \n" +
"\n" +
"${episodesString.shuffled().take(4).joinToString("\n") { "- $it" }}\n" +
"\n" +
"Bonne journée à tous !"

Constant.abstractSocialNetworks.parallelStream().forEach { socialNetwork ->
try {
socialNetwork.sendCalendar(message, calendarImageByteArray)
} catch (e: Exception) {
logger.log(
Level.SEVERE,
"Error while sending calendar for ${
socialNetwork.javaClass.simpleName.replace(
"SocialNetwork",
""
)
}",
e
)
}
}
}
}
}

private fun drawEpisodes(
episodes: List<CalendarEpisodeDto>,
graphics: Graphics2D,
baseY: Int,
episodeX: Int
): MutableList<String> {
var y = baseY
val episodesString = mutableListOf<String>()

episodes.sortedBy { it.platform.lowercase() }.groupBy { it.platform }.forEach { (platformName, episodes) ->
graphics.color = Color(0xFFFFFF)
graphics.font = graphics.font.deriveFont(22f)
val platform = Platform.findByName(platformName) ?: return@forEach

val platformImage = getPlatformImage(platform)
platformImage?.let { graphics.drawImage(it, 25, y - 25, null) }
graphics.drawString(platformName, 65, y)
graphics.fillRect(25, y + 15, (platformImage?.width ?: 0) + 10 + graphics.fontMetrics.stringWidth(platformName), 2)

episodes.sortedBy { it.anime.lowercase() }.groupBy { it.anime }.forEach { (anime, episodes) ->
val twoLines = if (episodes.size == 1) {
drawSingleEpisode(episodes.first(), graphics, anime, y, episodeX, episodesString)
} else {
drawMinMaxEpisodes(episodes, graphics, anime, y, episodeX, episodesString)
}

y += if (twoLines) 70 else 35
}

y += 100
}
return episodesString
}

private fun drawMinMaxEpisodes(
episodes: List<CalendarEpisodeDto>,
graphics: Graphics2D,
anime: String,
y: Int,
episodeX: Int,
episodesString: MutableList<String>
) = episodes.groupBy { it.season }.all { (season, episodes) ->
val numbers = episodes.mapNotNull { Regex("\\d+").find(it.episode)?.value?.toIntOrNull() }
val min = numbers.minOrNull() ?: 0
val max = numbers.maxOrNull() ?: 0

drawAnimeLine(graphics, anime, season, y).also {
graphics.drawString("Épisodes $min-$max", episodeX, y + 50)
episodesString.add("$anime${if (season > 1) " S${season}" else ""} - Épisodes $min-$max")
}
}

private fun drawSingleEpisode(
episode: CalendarEpisodeDto,
graphics: Graphics2D,
anime: String,
y: Int,
episodeX: Int,
episodesString: MutableList<String>
) = drawAnimeLine(graphics, anime, episode.season, y).also {
graphics.drawString(episode.episode, episodeX, y + 50)
episodesString.add("$anime${if (episode.season > 1) " S${episode.season}" else ""} - ${episode.episode}")
}

private fun getPlatformImage(platform: Platform): BufferedImage? {
val platformImage = runCatching {
ImageService.makeRoundedCorner(
ImageIO.read(
ClassLoader.getSystemClassLoader().getResourceAsStream("assets/img/platforms/${platform.image}")
).resize(32, 32), 360
)
}.getOrNull()
return platformImage
}

private fun getBackgroundImage(): BufferedImage? {
val calendarFolder =
ClassLoader.getSystemClassLoader()
.getResource("calendar")?.file?.let { File(it).takeIf { file -> file.exists() } }
?: File(
Constant.dataFolder,
"calendar"
)
require(calendarFolder.exists()) { "Calendar folder not found" }
val backgroundsFolder = File(calendarFolder, "backgrounds")
require(backgroundsFolder.exists()) { "Background folder not found" }
require(backgroundsFolder.listFiles()!!.isNotEmpty()) { "Backgrounds not found" }
val backgroundImage = ImageIO.read(backgroundsFolder.listFiles()!!.random())
return backgroundImage
}

private suspend fun getEpisodes(
elements: List<Element>,
httpRequest: HttpRequest
) = elements.mapNotNull { element ->
val animePageElement = element.selectFirst("a") ?: return@mapNotNull null
val url = animePageElement.attr("href").let { "https://anime.icotaku.com$it" }
val episode = element.selectFirst(".calendrier_episode")?.text() ?: return@mapNotNull null
var title = animePageElement.text().trim()
val season = Regex("Saison (\\d+)").find(title)?.groupValues?.get(1)?.toIntOrNull() ?: 1

// Get the anime page
val animePage = httpRequest.get(url)

if (animePage.status != HttpStatusCode.OK) {
return@mapNotNull null
}

val animeBody = Jsoup.parse(animePage.bodyAsText())
val list = animeBody.select(".info_fiche > div") ?: return@mapNotNull null
val licencePlatforms = list.find { it.text().contains("Licence VOD") }?.select("a")?.map { it.text() }?.toMutableList()
licencePlatforms?.removeIf { it.contains("TF1") }

if (licencePlatforms.isNullOrEmpty()) {
return@mapNotNull null
}

val alternativeTitles = list.find { it.text().contains("Titre alternatif") }?.text()?.replace("Titre alternatif :", "")?.split("/") ?: emptyList()
val detectedLanguage = languageCacheService.detectLanguage(title)

if (detectedLanguage != null && detectedLanguage == "fr" && alternativeTitles.isNotEmpty()) {
title = alternativeTitles.first().trim()
}

title = title.replace(Regex(" - Saison \\d+"), "").trim()

licencePlatforms.map {
CalendarEpisodeDto(
anime = StringUtils.getShortName(title),
season = season,
episode = episode.capitalizeWords(),
platform = it
)
}
}.flatten()

private fun drawAnimeLine(graphics: Graphics2D, anime: String, season: Int, y: Int): Boolean {
val s = "${anime}${if (season > 1) " S${season}" else ""}"
val width = graphics.fontMetrics.stringWidth(s)

// Split the anime name on two lines if it's too long
if (width > 750) {
val words = s.split(" ")
var separator = words.size / 2
val (firstHalf, secondHalf) = words.withIndex().partition { (index, _) -> index < separator }
var first = firstHalf.joinToString(" ") { it.value }
var second = secondHalf.joinToString(" ") { it.value }

// If the last word of the first line and the first word of the second line is the same, we move the separator
// Or if the first word of the first line and the first word of the second line is the same, we move the separator
if (first.split(" ").last() == second.split(" ").first() || first.split(" ").first() == second.split(" ").first()) {
separator++
val (firstHalfTry, secondHalfTry) = words.withIndex().partition { (index, _) -> index < separator }
first = firstHalfTry.joinToString(" ") { it.value }
second = secondHalfTry.joinToString(" ") { it.value }
}

graphics.drawString(first, 25, y + 50)
graphics.drawString(second, 25, y + 80)
return true
}

graphics.drawString(s, 25, y + 50)
return false
}
}
24 changes: 22 additions & 2 deletions src/main/kotlin/fr/shikkanime/services/ImageService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ object ImageService {
return Color(redAverage.toInt(), greenAverage.toInt(), blueAverage.toInt())
}

private fun makeRoundedCorner(image: BufferedImage, cornerRadius: Int): BufferedImage {
fun makeRoundedCorner(image: BufferedImage, cornerRadius: Int): BufferedImage {
return BufferedImage(image.width, image.height, BufferedImage.TYPE_INT_ARGB).apply {
createGraphics().apply {
composite = AlphaComposite.Src
Expand All @@ -319,7 +319,7 @@ object ImageService {
}
}

private fun Graphics2D.setRenderingHints() {
fun Graphics2D.setRenderingHints() {
setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB)
Expand Down Expand Up @@ -533,4 +533,24 @@ object ImageService {
graphics.dispose()
return finalImage
}

fun Graphics2D.drawStringRect(
text: String,
x: Int,
y: Int,
marginX: Int,
marginY: Int,
borderWidth: Int = 2,
backgroundColor: Color = Color(0x000),
) {
val fontMetrics = fontMetrics
val textWidth = fontMetrics.stringWidth(text)
val textHeight = fontMetrics.height

fillRoundRect(x, y, textWidth + marginX, textHeight + marginY, 10, 10)
color = backgroundColor
fillRoundRect(x + borderWidth, y + borderWidth, textWidth + marginX - (borderWidth * 2), textHeight + marginY - (borderWidth * 2), 10, 10)
color = Color.WHITE
drawString(text, x + marginX / 2, y + (marginY / 2) + textHeight - 5)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ abstract class AbstractSocialNetwork {
"${Constant.BASE_URL}/animes/${episodeDto.anime.slug}?utm_campaign=episode_post&utm_medium=social&utm_source=${utmSource()}&utm_content=${episodeDto.uuid}"

abstract fun sendEpisodeRelease(episodeDto: EpisodeDto, mediaImage: ByteArray)

open fun sendCalendar(message: String, calendarImage: ByteArray) {
// Default implementation
}
}
15 changes: 15 additions & 0 deletions src/main/kotlin/fr/shikkanime/socialnetworks/BskySocialNetwork.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,19 @@ class BskySocialNetwork : AbstractSocialNetwork() {
)
}
}

override fun sendCalendar(message: String, calendarImage: ByteArray) {
checkSession()
if (!isInitialized) return
val webpByteArray = FileManager.encodeToWebP(calendarImage)
val imageJson = runBlocking { BskyWrapper.uploadBlob(accessJwt!!, ContentType.parse("image/webp"), webpByteArray) }
runBlocking {
BskyWrapper.createRecord(
accessJwt!!,
did!!,
message,
listOf(BskyWrapper.Image(imageJson))
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,10 @@ class ThreadsSocialNetwork : AbstractSocialNetwork() {
val message = getEpisodeMessage(episodeDto, configCacheService.getValueAsString(ConfigPropertyKey.THREADS_MESSAGE) ?: "")
runBlocking { threadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, mediaImage) }
}

override fun sendCalendar(message: String, calendarImage: ByteArray) {
checkSession()
if (!isInitialized) return
runBlocking { threadsWrapper.publish(username!!, deviceId!!, userId!!, token!!, message, calendarImage) }
}
}
Loading

0 comments on commit abaa7e9

Please sign in to comment.