Skip to content

Commit

Permalink
Extract user messages
Browse files Browse the repository at this point in the history
  • Loading branch information
kubukoz committed Apr 12, 2021
1 parent 2d9ae7e commit f573e62
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 42 deletions.
8 changes: 3 additions & 5 deletions src/main/scala/com/kubukoz/next/ConfigLoader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,13 @@ object ConfigLoader {
}
}

def withCreateFileIfMissing[F[_]: Console: MonadThrow](configPath: Path): ConfigLoader[F] => ConfigLoader[F] = {
implicit val showPath: Show[Path] = Show.fromToString
def withCreateFileIfMissing[F[_]: UserOutput: Console: MonadThrow](configPath: Path): ConfigLoader[F] => ConfigLoader[F] = {

val validInput = "Y"
val askMessage = show"Didn't find config file at $configPath. Should I create one? ($validInput/n)"

def askToCreateFile(originalException: NoSuchFileException): F[Config] =
for {
_ <- Console[F].println(askMessage)
_ <- UserOutput[F].print(UserMessage.ConfigFileNotFound(configPath, validInput))
_ <- Console[F].readLine.map(_.trim).ensure(originalException)(_.equalsIgnoreCase(validInput))
clientId <- ConsoleRead.readWithPrompt[F, String]("Client ID")
clientSecret <- ConsoleRead.readWithPrompt[F, String]("Client secret")
Expand All @@ -51,7 +49,7 @@ object ConfigLoader {
new ConfigLoader[F] {
val loadConfig: F[Config] = underlying.loadConfig.recoverWith { case e: NoSuchFileException =>
askToCreateFile(e).flatTap(saveConfig) <*
Console[F].println(s"Saved config to new file at $configPath")
UserOutput[F].print(UserMessage.SavedConfig(configPath))
}

def saveConfig(config: Config): F[Unit] = underlying.saveConfig(config)
Expand Down
5 changes: 2 additions & 3 deletions src/main/scala/com/kubukoz/next/Login.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.kubukoz.next
import cats.effect.kernel.Async
import cats.effect.kernel.Deferred
import cats.effect.kernel.Resource
import cats.effect.std.Console
import cats.implicits._
import com.kubukoz.next.Login.Tokens
import com.kubukoz.next.api.spotify.RefreshedTokenResponse
Expand Down Expand Up @@ -37,7 +36,7 @@ object Login {

final case class Code(value: String)

def blaze[F[_]: Console: Config.Ask: Async](
def blaze[F[_]: UserOutput: Config.Ask: Async](
client: Client[F]
): Login[F] =
new Login[F] {
Expand Down Expand Up @@ -80,7 +79,7 @@ object Login {
.withQueryParam("response_type", "code")
}
.flatMap { uri =>
Console[F].println(s"Go to $uri")
UserOutput[F].print(UserMessage.GoToUri(uri))
}

def mkServer(config: Config, route: HttpRoutes[F]) =
Expand Down
24 changes: 14 additions & 10 deletions src/main/scala/com/kubukoz/next/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cats.data.NonEmptyList
import cats.effect.ExitCode
import cats.effect.IO
import cats.effect.Resource
import cats.effect.kernel.Async
import cats.effect.std.Console
import cats.implicits._
import com.kubukoz.next.util.Config
Expand Down Expand Up @@ -46,32 +47,35 @@ object Main extends CommandIOApp(name = "spotify-next", header = "spotify-next:

import Program._

def runApp[F[_]: Spotify: ConfigLoader: Login: Console: Monad]: Choice => F[Unit] = {
def runApp[F[_]: Spotify: ConfigLoader: Login: UserOutput: Monad]: Choice => F[Unit] = {
case Choice.Login => loginUser[F]
case Choice.SkipTrack => Spotify[F].skipTrack
case Choice.DropTrack => Spotify[F].dropTrack
case Choice.FastForward(p) => Spotify[F].fastForward(p)
}

val makeProgram: Resource[IO, Choice => IO[Unit]] =
def makeProgram[F[_]: Async: Console]: Resource[F, Choice => F[Unit]] = {
implicit val userOutput: UserOutput[F] = UserOutput.toConsole

Resource
.eval(makeLoader[IO])
.eval(makeLoader[F])
.flatMap { implicit loader =>
implicit val configAsk: Config.Ask[IO] = loader.configAsk
implicit val configAsk: Config.Ask[F] = loader.configAsk

makeBasicClient[IO].evalMap { rawClient =>
implicit val login: Login[IO] = Login.blaze[IO](rawClient)
makeBasicClient[F].evalMap { rawClient =>
implicit val login: Login[F] = Login.blaze[F](rawClient)

makeSpotify(apiClient[IO].apply(rawClient)).map { implicit spotify =>
runApp[IO]
makeSpotify(apiClient[F].apply(rawClient)).map { implicit spotify =>
runApp[F]
}
}
}
}

val mainOpts: Opts[IO[Unit]] = Choice
.opts
.map { choice =>
makeProgram.use(_.apply(choice))
makeProgram[IO].use(_.apply(choice))
}

val runRepl: IO[Unit] = {
Expand All @@ -93,7 +97,7 @@ object Main extends CommandIOApp(name = "spotify-next", header = "spotify-next:
fs2.Stream.exec(IO.println("Loading REPL...")) ++
fs2
.Stream
.resource(makeProgram)
.resource(makeProgram[IO])
.evalTap(_ => IO.println("Welcome to the spotify-next REPL! Type in a command to begin"))
.map(Command("", "")(Choice.opts).map(_))
.flatMap { command =>
Expand Down
22 changes: 12 additions & 10 deletions src/main/scala/com/kubukoz/next/Program.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package com.kubukoz.next

import java.lang.System
import java.nio.file.Paths

import cats.Monad
import cats.MonadError
import cats.effect.Concurrent
Expand All @@ -24,10 +21,13 @@ import org.http4s.client.middleware.FollowRedirect
import org.http4s.client.middleware.RequestLogger
import org.http4s.client.middleware.ResponseLogger

import java.lang.System
import java.nio.file.Paths

object Program {
val configPath = Paths.get(System.getProperty("user.home")).resolve(".spotify-next.json")

def makeLoader[F[_]: Files: Ref.Make: Console: fs2.Compiler.Target]: F[ConfigLoader[F]] =
def makeLoader[F[_]: Files: Ref.Make: UserOutput: Console: fs2.Compiler.Target]: F[ConfigLoader[F]] =
ConfigLoader
.cached[F]
.compose(ConfigLoader.withCreateFileIfMissing[F](configPath))
Expand All @@ -43,7 +43,9 @@ object Program {
.map(RequestLogger(logHeaders = true, logBody = true))
.map(ResponseLogger(logHeaders = true, logBody = true))

def apiClient[F[_]: Console: ConfigLoader: Login: MonadCancelThrow](implicit SC: fs2.Compiler[F, F]): Client[F] => Client[F] = {
def apiClient[F[_]: UserOutput: Console: ConfigLoader: Login: MonadCancelThrow](
implicit SC: fs2.Compiler[F, F]
): Client[F] => Client[F] = {
implicit val configAsk: Config.Ask[F] = ConfigLoader[F].configAsk
implicit val tokenAsk: Token.Ask[F] = Token.askBy(configAsk)(Getter(_.token))

Expand All @@ -63,27 +65,27 @@ object Program {
}

// Do NOT move this into Spotify, it'll vastly increase the range of its responsibilities!
def loginUser[F[_]: Console: Login: ConfigLoader: Monad]: F[Unit] =
def loginUser[F[_]: UserOutput: Login: ConfigLoader: Monad]: F[Unit] =
for {
tokens <- Login[F].server
config <- ConfigLoader[F].loadConfig
newConfig = config.copy(token = tokens.access.some, refreshToken = tokens.refresh.some)
_ <- ConfigLoader[F].saveConfig(newConfig)
_ <- Console[F].println("Saved token to file")
_ <- UserOutput[F].print(UserMessage.SavedToken)
} yield ()

def refreshUserToken[F[_]: Console: Login: ConfigLoader: MonadError[*[_], Throwable]](
def refreshUserToken[F[_]: UserOutput: Login: ConfigLoader: MonadError[*[_], Throwable]](
refreshToken: RefreshToken
): F[Unit] =
for {
newToken <- Login[F].refreshToken(refreshToken)
config <- ConfigLoader[F].loadConfig
newConfig = config.copy(token = newToken.some)
_ <- ConfigLoader[F].saveConfig(newConfig)
_ <- Console[F].println("Refreshed token") //todo debug level?
_ <- UserOutput[F].print(UserMessage.RefreshedToken)
} yield ()

def makeSpotify[F[_]: Console: Concurrent](client: Client[F]): F[Spotify[F]] = {
def makeSpotify[F[_]: UserOutput: Concurrent](client: Client[F]): F[Spotify[F]] = {
implicit val theClient = client

import org.http4s.syntax.all._
Expand Down
26 changes: 12 additions & 14 deletions src/main/scala/com/kubukoz/next/Spotify.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import java.time.Duration

import cats.data.Kleisli
import cats.effect.Concurrent
import cats.effect.std.Console
import cats.implicits._
import com.kubukoz.next.api.sonos
import com.kubukoz.next.api.spotify.Item
Expand Down Expand Up @@ -38,13 +37,10 @@ object Spotify {
final case class InvalidContext[T](ctx: T) extends Error
final case class InvalidItem[T](ctx: T) extends Error

def instance[F[_]: Playback: Client: Console: Concurrent]: Spotify[F] =
def instance[F[_]: Playback: Client: UserOutput: Concurrent]: Spotify[F] =
new Spotify[F] {
val client = implicitly[Client[F]]

val console = Console[F]
import console._

private def requirePlaylist[A](player: Player[Option[PlayerContext], A]): F[Player[PlayerContext.playlist, A]] =
player
.unwrapContext
Expand All @@ -58,15 +54,15 @@ object Spotify {
.flatMap(_.narrowItem[Item.track].liftTo[F])

val skipTrack: F[Unit] =
println("Switching to next track") *>
UserOutput[F].print(UserMessage.SwitchingToNext) *>
Playback[F].nextTrack

val dropTrack: F[Unit] =
methods.player[F].run(client).flatMap(requirePlaylist(_)).flatMap(requireTrack).flatMap { player =>
val trackUri = player.item.uri
val playlistId = player.context.uri.playlist

println(show"""Removing track "${player.item.name}" ($trackUri) from playlist $playlistId""") *>
UserOutput[F].print(UserMessage.RemovingCurrentTrack(player)) *>
skipTrack *>
methods.removeTrack[F](trackUri, playlistId).run(client)
}
Expand All @@ -83,11 +79,13 @@ object Spotify {
}
.flatMap {
case (_, desiredProgressPercent) if desiredProgressPercent >= 100 =>
println("Too close to song's ending, rewinding to beginning") *> Playback[F].seek(0)
UserOutput[F].print(UserMessage.TooCloseToEnd) *>
Playback[F].seek(0)

case (player, desiredProgressPercent) =>
val desiredProgressMs = desiredProgressPercent * player.item.durationMs / 100
println(show"Seeking to $desiredProgressPercent%") *> Playback[F].seek(desiredProgressMs)
UserOutput[F].print(UserMessage.Seeking(desiredProgressPercent)) *>
Playback[F].seek(desiredProgressMs)
}

}
Expand Down Expand Up @@ -124,8 +122,8 @@ object Spotify {

}

def build[F[_]: Concurrent: Console](sonosBaseUrl: Uri, client: Client[F]): F[Playback[F]] =
Console[F].println(show"Checking if Sonos API is available at $sonosBaseUrl...") *>
def build[F[_]: UserOutput: Concurrent](sonosBaseUrl: Uri, client: Client[F]): F[Playback[F]] =
UserOutput[F].print(UserMessage.CheckingSonos(sonosBaseUrl)) *>
client
.get(sonosBaseUrl / "zones") {
case response if response.status.isSuccess => response.as[sonos.SonosZones].map(_.some)
Expand All @@ -134,13 +132,13 @@ object Spotify {
.handleError(_ => None)
.flatMap {
case None =>
Console[F].println("Sonos not found, will access Spotify API directly").as(spotify(client))
UserOutput[F].print(UserMessage.SonosNotFound).as(spotify(client))

case Some(zones) =>
val roomName = zones.zones.head.coordinator.roomName

Console[F]
.println(show"Found ${zones.zones.size} zone(s), will use room $roomName")
UserOutput[F]
.print(UserMessage.SonosFound(zones, roomName))
.as(localSonos(sonosBaseUrl, roomName, client))
}

Expand Down
68 changes: 68 additions & 0 deletions src/main/scala/com/kubukoz/next/UserMessage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.kubukoz.next

import cats.Show
import cats.effect.std
import cats.implicits._
import cats.~>
import com.kubukoz.next.api.sonos.SonosZones
import com.kubukoz.next.api.spotify.Item
import com.kubukoz.next.api.spotify.Player
import com.kubukoz.next.api.spotify.PlayerContext
import org.http4s.Uri

import java.nio.file.Path

sealed trait UserMessage extends Product with Serializable

object UserMessage {
final case class GoToUri(uri: Uri) extends UserMessage
final case class ConfigFileNotFound(path: Path, validInput: String) extends UserMessage
final case class SavedConfig(path: Path) extends UserMessage
case object SavedToken extends UserMessage
case object RefreshedToken extends UserMessage

// playback
case object SwitchingToNext extends UserMessage
final case class RemovingCurrentTrack(player: Player[PlayerContext.playlist, Item.track]) extends UserMessage
case object TooCloseToEnd extends UserMessage
final case class Seeking(desiredProgressPercent: Int) extends UserMessage

// setup
final case class CheckingSonos(url: Uri) extends UserMessage
case object SonosNotFound extends UserMessage
final case class SonosFound(zones: SonosZones, roomName: String) extends UserMessage
}

trait UserOutput[F[_]] {
def print(msg: UserMessage): F[Unit]
def mapK[G[_]](fk: F ~> G): UserOutput[G] = msg => fk(print(msg))
}

object UserOutput {
def apply[F[_]](implicit F: UserOutput[F]): UserOutput[F] = F

def toConsole[F[_]: std.Console]: UserOutput[F] = {
implicit val showPath: Show[Path] = Show.fromToString

import UserMessage._

val stringify: UserMessage => String = {
case GoToUri(uri) => show"Go to $uri"
case ConfigFileNotFound(path, validInput) => show"Didn't find config file at $path. Should I create one? ($validInput/n)"
case SavedConfig(path) => show"Saved config to new file at $path"
case SavedToken => "Saved token to file"
case RefreshedToken => "Refreshed token"
case SwitchingToNext => "Switching to next track"
case RemovingCurrentTrack(player) =>
show"""Removing track "${player.item.name}" (${player.item.uri}) from playlist ${player.context.uri.playlist}"""
case TooCloseToEnd => "Too close to song's ending, rewinding to beginning"
case Seeking(desiredProgressPercent) => show"Seeking to $desiredProgressPercent%"
case CheckingSonos(url) => show"Checking if Sonos API is available at $url..."
case SonosNotFound => "Sonos not found, will access Spotify API directly"
case SonosFound(zones, roomName) => show"Found ${zones.zones.size} zone(s), will use room $roomName"
}

msg => std.Console[F].println(stringify(msg))
}

}

0 comments on commit f573e62

Please sign in to comment.