/** * TheCitadelofRicks * * Copyright 2019-2020 * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * MA 02110-1301, USA. * */ package org.mosad.thecitadelofricks.controller import com.google.gson.Gson import kotlinx.coroutines.* import org.jsoup.Jsoup import org.mosad.thecitadelofricks.* import org.mosad.thecitadelofricks.hsoparser.CourseListParser import org.mosad.thecitadelofricks.hsoparser.MensaParser import org.mosad.thecitadelofricks.hsoparser.TimetableParser import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.* import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import kotlin.collections.ArrayList import kotlin.collections.HashSet import kotlin.concurrent.scheduleAtFixedRate import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime class CacheController { init { initUpdates() scheduledUpdates() } companion object { private val logger: Logger = LoggerFactory.getLogger(CacheController::class.java) var courseList = CoursesList(CoursesMeta(), sortedMapOf()) var mensaMenu = MensaMenu(MensaMeta(0, ""), MensaWeek(), MensaWeek()) var timetableList = ConcurrentHashMap() // this list contains all timetables /** * get a timetable, since they may not be cached, we need to make sure it's cached, otherwise download * @param courseName the name of the course to be requested * @param weekIndex request week number (current week = 0) * @return timetable of the course (Type: [TimetableCourseWeek]) */ fun getTimetable(courseName: String, weekIndex: Int): TimetableCourseWeek { // TODO just for testing if (courseName == "TEST_A" || courseName == "TEST_B") { val currentTime = System.currentTimeMillis() / 1000 val timetableLink = "https://mosad.xyz" val weekNumberYear = 0 val year = 0 val instr = CacheController::class.java.getResourceAsStream("/html/Timetable_normal-week.html") val timetableParser = TimetableParser(htmlDoc = Jsoup.parse(instr!!, "UTF-8", "https://www.hs-offenburg.de/")) val timetableTest = timetableParser.parseTimeTable() return TimetableCourseWeek( TimetableCourseMeta( currentTime, courseName, weekIndex, weekNumberYear, year, timetableLink ), timetableTest ?: TimetableWeek() ) } val key = "$courseName-$weekIndex" return if (timetableList.containsKey(key)) { timetableList[key]!! } else { val timetableLink = courseList.courses[courseName] ?.courseLink ?.replace("week=0", "week=$weekIndex") ?: "" val currentTime = System.currentTimeMillis() / 1000 val timetableParser = TimetableParser(timetableLink) val calendarWeek = timetableParser.parseCalendarWeek() val timetable = timetableParser.parseTimeTable() TimetableCourseWeek( TimetableCourseMeta( currentTime, courseName, weekIndex, calendarWeek?.week ?: 0, calendarWeek?.year ?: 0, timetableLink ), timetable ?: TimetableWeek() ).also { if (timetable != null) timetableList[key] = it } } } /** * get every explicit lesson in a week for a selected course * @param courseName the name of the course to be requested * @param weekIndex request week number (current week = 0) * @return a HashSet of explicit lessons for one week */ fun getLessonSubjectList(courseName: String, weekIndex: Int): HashSet = runBlocking { val lessonSubjectList = ArrayList() // get every lesson subject for the given week val flatMap = getTimetable(courseName, weekIndex).timetable.days.flatMap { it.timeslots.asIterable() } flatMap.forEach { it.stream().filter { x -> x.lessonSubject.isNotEmpty() }.findAny().ifPresent { x -> lessonSubjectList.add(x.lessonSubject) } } return@runBlocking HashSet(lessonSubjectList) } /** * get every lesson of a subject in a week * @param courseName the name of the course to be requested * @param lessonSubject the lesson subject to be requested * @param weekIndex request week number (current week = 0) * @return a ArrayList<[Lesson]> of every lesson with lessonSubject for one week */ fun getLesson(courseName: String, lessonSubject: String, weekIndex: Int): ArrayList { val lessonList = ArrayList() // get all lessons from the weeks timetable val flatMap = getTimetable(courseName, weekIndex).timetable.days.flatMap { it.timeslots.asIterable() } flatMap.forEach { it.stream().filter { x -> x.lessonSubject.contains(lessonSubject) }.findAny().ifPresent { x -> lessonList.add(x) } } return lessonList } // private cache functions /** * this function updates the courseList * during the update process the old data will be returned for an API request */ private fun asyncUpdateCourseList() = GlobalScope.launch { CourseListParser().getCourseLinks(StartupController.courseListURL)?.let { courseList = CoursesList(CoursesMeta(System.currentTimeMillis() / 1000, it.size), it.toSortedMap()) } // TODO just for testing courseList.courses["TEST_A"] = Course("TEST_A", "https://mosad.xyz") courseList.courses["TEST_B"] = Course("TEST_B", "https://mosad.xyz") logger.info("Updated courses successful at ${Date(courseList.meta.updateTime * 1000)}") } /** * this function updates the mensa menu list * during the update process the old data will be returned for an API request */ private fun asyncUpdateMensa() = GlobalScope.launch { val mensaCurrentWeek = MensaParser().getMensaMenu(StartupController.mensaMenuURL) val mensaNextWeek = MensaParser().getMensaMenu(MensaParser().getMenuLinkNextWeek(StartupController.mensaMenuURL)) // only update if we get valid data if (mensaCurrentWeek != null && mensaNextWeek != null) { mensaMenu = MensaMenu(MensaMeta(System.currentTimeMillis() / 1000, StartupController.mensaName), mensaCurrentWeek, mensaNextWeek) } logger.info("Updated mensamenu successful at ${Date(mensaMenu.meta.updateTime * 1000)}") } /** * this function updates all existing timetables * during the update process the old data will be returned for an API request * a FixedThreadPool is used to make parallel requests for faster updates */ private fun asyncUpdateTimetables() = GlobalScope.launch { logger.info("Updating ${timetableList.size} timetables ...") // create a new ThreadPool with 5 threads val executor = Executors.newFixedThreadPool(5) try { timetableList.forEach { timetableCourse -> executor.execute { val timetableParser = TimetableParser(timetableCourse.value.meta.link) timetableCourse.value.timetable = timetableParser.parseTimeTable() ?: return@execute timetableCourse.value.meta.weekNumberYear = timetableParser.parseCalendarWeek()?.week ?: return@execute timetableCourse.value.meta.updateTime = System.currentTimeMillis() / 1000 saveTimetableToCache(timetableCourse.value) // save the updated timetable to the cache directory } } } catch (ex: Exception) { logger.error("Error while updating the timetables", ex) } finally { executor.shutdown() } } /** * save a timetable to the cache directory * this is only call on async updates, it is NOT call when first getting the timetable * @param timetable a timetable of the type [TimetableCourseWeek] */ private fun saveTimetableToCache(timetable: TimetableCourseWeek) { val file = File(StartupController.dirTcorCache, "timetable-${timetable.meta.courseName}-${timetable.meta.weekIndex}.json") val writer = BufferedWriter(FileWriter(file)) try { writer.write(Gson().toJson(timetable)) } catch (e: Exception) { logger.error("something went wrong while trying to write a cache file", e) } finally { writer.close() } } /** * before the APIController is up, get the data fist * runBlocking: otherwise the api would return no data to requests for a few seconds after startup */ private fun initUpdates() = runBlocking { // get all course links on startup, make sure there are course links val jobCourseUpdate = asyncUpdateCourseList() val jobMensa = asyncUpdateMensa() jobCourseUpdate.join() jobMensa.join() logger.info("Initial updates successful") } /** * update the CourseList every 24h, the Timetables every 3h and the Mensa Menu every hour * doesn't account the change between winter and summer time! */ @OptIn(ExperimentalTime::class) private fun scheduledUpdates() { val currentTime = System.currentTimeMillis() val duration24h = 24.hours.inWholeMilliseconds val duration3h = 3.hours.inWholeMilliseconds val duration1h = 1.hours.inWholeMilliseconds val duration1m = 1.minutes.inWholeMilliseconds // Calculate the initial delay to make the update time independent of the start time fun calcInitDelay(period: Long) = (period - ((currentTime + duration1h) % period)) + duration1m val initDelay24h = calcInitDelay(duration24h) val initDelay3h = calcInitDelay(duration3h) val initDelay1h = calcInitDelay(duration1h) // update courseList every 24 hours (time in ms) Timer().scheduleAtFixedRate(initDelay24h, duration24h) { asyncUpdateCourseList() } // update all already existing timetables every 3 hours (time in ms) Timer().scheduleAtFixedRate(initDelay3h, duration3h) { asyncUpdateTimetables() } // update courses every hour (time in ms) Timer().scheduleAtFixedRate(initDelay1h, duration1h) { asyncUpdateMensa() } // post to status.mosad.xyz every hour, if an API key is present if (StartupController.cachetAPIKey != "0") { Timer().scheduleAtFixedRate(initDelay1h, duration1h) { CachetAPIController.postTotalRequests() } } } } }