All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.mchange.unifyrss.effect.scala Maven / Gradle / Ivy

package com.mchange.unifyrss

import scala.collection.*
import zio.*

import java.io.InputStream
import java.net.URL
import java.nio.file.{Files, Path as JPath}
import java.lang.System

import audiofluidity.rss.Element
import audiofluidity.rss.atom.rssElemFromAtomFeedElem
import audiofluidity.rss.util.scopeContainsRaw


import scala.xml.{Elem, NamespaceBinding, PrettyPrinter, TopScope, XML}
import unstatic.UrlPath.*

private val ExponentialBackoffFactor = 1.5d

private val FirstErrorRetry  = 10.seconds
private val InitLongestRetry = 600.seconds

private val QuickRetryPeriod = 6.seconds
private val QuickRetryLimit  = 60.seconds

def retrySchedule( normalRefresh : Duration, firstErrorRetry : Duration = FirstErrorRetry ) =
  Schedule.exponential( firstErrorRetry, ExponentialBackoffFactor ) || Schedule.fixed( normalRefresh )

val quickRetrySchedule =
  Schedule.spaced(QuickRetryPeriod).upTo(QuickRetryLimit)

private def errorEmptyRssElement(mf : MergedFeed, location : String, description : String) : Element.Rss =
  val badFeeds = if mf.sourceUrls.isEmpty then "none" else mf.sourceUrls.map( u => s"'${u.toString}'").mkString(", ")
  val badMetaSources = if mf.metaSources.isEmpty then "none" else mf.metaSources.map( ms => s"'${ms.toString}'").mkString(", ")
  val channel = Element.Channel.create(
    title = s"ERROR — Failed to merge feeds, problem at location '${location}'. Feeds: [${badFeeds}], MetaSources: [${badMetaSources}]",
    linkUrl = "about:blank",
    description = description,
    items = List.empty
  )
  Element.Rss(channel)

def fetchElem( url : URL ) : Task[Elem] =
  ZIO.attemptBlocking{
    // XML.load(url) // in practice, loading via requests-scala proves more reliable, especially for long documents
    requests.get.stream( url.toString ).readBytesThrough( XML.load )
  }
    .mapError( e => new XmlFetchFailure( s"Problem loading: ${url}", e ) )

def fetchElem( sourceUrl : SourceUrl ) : Task[Elem] = fetchElem( sourceUrl.url ).map( sourceUrl.transformer )

def bestAttemptFetchElem(sourceUrl : SourceUrl) : Task[Option[Elem]] =
  fetchElem(sourceUrl)
    .logError
    .retry( quickRetrySchedule )
    .foldCauseZIO(cause => ZIO.logCause(s"Problem loading feed '${sourceUrl.url}'", cause) *> ZIO.succeed(None), elem => ZIO.succeed(Some(elem)))

def bestAttemptFetchSourceUrls( ms : MetaSource ) : Task[immutable.Seq[SourceUrl]] =
  ZIO.attempt( ms.sourceUrls )
     .logError
     .retry( quickRetrySchedule )
     .foldCauseZIO(cause => ZIO.logCause(s"Problem loading MetaSource '${ms}'", cause) *> ZIO.succeed(immutable.Seq.empty), sourceUrls => ZIO.succeed(sourceUrls))

def bestAttemptFetchElems(mf : MergedFeed) : Task[immutable.Seq[Elem]] =
  val raw = 
    for
      fromMetaSources <- ZIO.collectAllPar(mf.metaSources.map( bestAttemptFetchSourceUrls )).map( _.flatten )
      allSourceUrls   =  mf.sourceUrls ++ fromMetaSources
      maybeElems      <- ZIO.collectAllPar(allSourceUrls.map( bestAttemptFetchElem) )
    yield
      maybeElems
        .collect { case Some(elem) => elem }
  raw.rejectZIO:
    case fetched if fetched.isEmpty =>
      ZIO.fail(XmlFetchFailure(s"Could load no feeds for merged feed at '${mf.feedPath}'"))

def elemToRssElem( elem : Elem ) : Elem =
  if elem.prefix == null then // we expect no prefix on top-level elements
    elem.label match
      case "rss" => elem
      case "feed" if scopeContainsRaw( null, "http://www.w3.org/2005/Atom", elem.scope ) => rssElemFromAtomFeedElem(elem)
      case _ => throw CantConvertToRss(s"Unknown feed type with label '${elem.label}' and scope '${elem.scope}'")
  else
    throw new CantConvertToRss (
      s"No prefixed elements are currently convertible to RSS, elem.prefix: ${elem.prefix}, elem.label: ${elem.label}, elem.scope: ${elem.scope}"
    )

def attemptElemToRssElem( elem : Elem ) : Task[Option[Elem]] =
  ZIO.attempt( Some(elemToRssElem(elem)) ).logError.catchAll( _ => ZIO.succeed(None : Option[Elem]) )

def bestAttemptElemsToRssElems( elems : immutable.Seq[Elem] ) : Task[immutable.Seq[Option[Elem]]] =
  ZIO.mergeAll( elems.map( attemptElemToRssElem ) )( immutable.Seq.empty[Option[Elem]])( _ :+ _)

def bestAttemptFetchFeeds(mf : MergedFeed) : Task[immutable.Seq[Elem]] =
  for
    elems <- bestAttemptFetchElems(mf)
    converted <- bestAttemptElemsToRssElems( elems )
  yield
    converted.collect{ case Some(elem) => elem }

def bestAttemptFetchFeedsOrEmptyFeed(mf : MergedFeed) : UIO[immutable.Seq[Elem]] =
  bestAttemptFetchFeeds(mf).logError.catchAll( t => ZIO.succeed(List(errorEmptyRssElement(mf, "bestAttemptFetchFeedsOrEmptyFeed", t.toString).toElem) ) )

def elemToBytes( elem : Elem ) : immutable.Seq[Byte] =
  val pp = new PrettyPrinter(width=120, step=2, minimizeEmpty=true)
  val text = s"\n${pp.format(elem)}"
  immutable.ArraySeq.ofByte(text.getBytes(scala.io.Codec.UTF8.charSet))

def mergeFeeds(bc : BaseConfig, mf : MergedFeed, feeds : immutable.Seq[Elem]) : Task[immutable.Seq[Byte]] = ZIO.attempt:
  val spec = Element.Channel.Spec(mf.title(feeds), bc.appPathAbs.resolve(mf.stubSitePath).toString, mf.description(feeds))
  val rssElement = RssMerger.merge(bc.appPathAbs.resolve(mf.feedPath).toString, spec, mf.itemLimit, feeds*)
  val xformed = mf.outputTransformer( rssElement.toElem )
  elemToBytes( xformed )

def bestAttemptMergeFeeds(bc : BaseConfig, mf : MergedFeed, feeds : immutable.Seq[Elem]) : UIO[immutable.Seq[Byte]] =
  mergeFeeds(bc, mf, feeds)
    .logError
    .catchAll( t => ZIO.succeed(errorEmptyRssElement(mf, "bestAttemptFetchFeedsOrEmptyFeed", t.toString).bytes) )

def stubSite(dc : DaemonConfig, mf : MergedFeed, feeds : immutable.Seq[Elem]) : Task[String] = ZIO.attempt(mf.stubSite(feeds))

def staticGenMergedFeeds( bc : BaseConfig, appStaticDir : JPath ) : Task[Unit] =
  def genFeed( mf : MergedFeed ) =
    val destPath = appStaticDir.resolve( mf.feedPath.toString )
    for
      elems <- bestAttemptFetchFeedsOrEmptyFeed(mf)
      feed  <- bestAttemptMergeFeeds( bc, mf, elems )
    yield
      val destPathDir = destPath.getParent
      if !Files.exists(destPathDir) then Files.createDirectories(destPathDir)
      Files.write( destPath, feed.toArray )
      System.err.println(s"Wrote feed to ${destPath}")
  ZIO.collectAllParDiscard( bc.mergedFeeds.map( mf => genFeed(mf).logError ) )

def staticGenMergedFeeds( sgc : StaticGenConfig ) : Task[Unit] =
  staticGenMergedFeeds(sgc, sgc.appStaticDir)

def initMergedFeedRefs( dc : DaemonConfig ) : Task[FeedRefMap] =
  def refTup( mf : MergedFeed ) =
    for
      elems <- bestAttemptFetchFeedsOrEmptyFeed(mf)
      feed  <- bestAttemptMergeFeeds( dc, mf, elems )
      ref   <- Ref.make(feed).logError  // let's be sure to see if anything goes wrong
    yield (mf.feedPath, ref)
  val tupEffects = dc.mergedFeeds.map( refTup )
  ZIO.mergeAll(tupEffects)( immutable.Map.empty[Rel,Ref[immutable.Seq[Byte]]] )( (accum, next) => accum + next )

def updateMergedFeedRef( dc : DaemonConfig, mf : MergedFeed, mergedFeedRefs : FeedRefMap ) : Task[Unit] =
  for
    elems <- bestAttemptFetchFeedsOrEmptyFeed(mf)
    feed  <- bestAttemptMergeFeeds(dc, mf, elems)
    _     <- mergedFeedRefs(mf.feedPath).set(feed)
  yield ()

def periodicallyResilientlyUpdateMergedFeedRef( dc : DaemonConfig, mf : MergedFeed, mergedFeedRefs : immutable.Map[Rel,Ref[immutable.Seq[Byte]]] ) : Task[Long] =
  val refreshDuration = Duration.fromSeconds(mf.refreshSeconds)
  val resilient = updateMergedFeedRef( dc, mf, mergedFeedRefs ).logError.retry( retrySchedule( refreshDuration ) ) // let's be sure to see if anything goes wrong
  resilient.schedule( Schedule.fixed( refreshDuration ) )

def periodicallyResilientlyUpdateAllMergedFeedRefs( dc : DaemonConfig, mergedFeedRefs : immutable.Map[Rel,Ref[immutable.Seq[Byte]]] ) =
  val allForks = dc.mergedFeeds.map( mf => periodicallyResilientlyUpdateMergedFeedRef( dc, mf, mergedFeedRefs ).forkDaemon )
  ZIO.collectAllDiscard( allForks )




© 2015 - 2025 Weber Informatics LLC | Privacy Policy