broadcast tournament/round WIP

broadcast-tournament
Thibault Duplessis 2021-04-25 12:30:46 +02:00
parent 6773ef6e46
commit dc240c87c0
31 changed files with 370 additions and 269 deletions

View File

@ -20,7 +20,7 @@ final class RelayRound(
Auth { implicit ctx => me =>
NoLameOrBot {
WithTourAndRounds(tourId) { trs =>
(trs.tour.ownerId == me.id) ?? {
trs.tour.ownedBy(me) ?? {
Ok(html.relay.roundForm.create(env.relay.roundForm.create(trs), trs.tour)).fuccess
}
}
@ -33,7 +33,7 @@ final class RelayRound(
me =>
NoLameOrBot {
WithTourAndRounds(tourId) { trs =>
(trs.tour.ownerId == me.id) ?? {
trs.tour.ownedBy(me) ?? {
env.relay.roundForm
.create(trs)
.bindFromRequest()(ctx.body, formBinding)
@ -58,7 +58,11 @@ final class RelayRound(
.bindFromRequest()(req, formBinding)
.fold(
err => BadRequest(apiFormError(err)).fuccess,
setup => env.relay.api.create(setup, me, tour) map env.relay.jsonView.admin map JsonOk
setup =>
env.relay.api
.create(setup, me, tour)
.map(_ withTour tour)
.map(env.relay.jsonView.admin) map JsonOk
)
}
}
@ -68,7 +72,7 @@ final class RelayRound(
def edit(id: String) =
Auth { implicit ctx => me =>
OptionFuResult(env.relay.api.byIdAndContributor(id, me)) { rt =>
Ok(html.relay.roundForm.edit(rt, env.relay.roundForm.edit(rt.relay))).fuccess
Ok(html.relay.roundForm.edit(rt, env.relay.roundForm.edit(rt.round))).fuccess
}
}
@ -93,7 +97,7 @@ final class RelayRound(
case Some(res) =>
res.fold(
{ case (_, err) => BadRequest(apiFormError(err)) },
rt => JsonOk(env.relay.jsonView.admin(rt.relay))
rt => JsonOk(env.relay.jsonView.admin(rt))
)
}
)
@ -104,12 +108,12 @@ final class RelayRound(
env.relay.api.byIdAndContributor(id, me) flatMap {
_ ?? { rt =>
env.relay.roundForm
.edit(rt.relay)
.edit(rt.round)
.bindFromRequest()
.fold(
err => fuccess(Left(rt -> err)),
data =>
env.relay.api.update(rt.relay) { data.update(_, me) }.dmap(_ withTour rt.tour) dmap Right.apply
env.relay.api.update(rt.round) { data.update(_, me) }.dmap(_ withTour rt.tour) dmap Right.apply
) dmap some
}
}
@ -117,7 +121,7 @@ final class RelayRound(
def reset(id: String) =
Auth { implicit ctx => me =>
OptionFuResult(env.relay.api.byIdAndContributor(id, me)) { rt =>
env.relay.api.reset(rt.relay, me) inject Redirect(rt.path)
env.relay.api.reset(rt.round, me) inject Redirect(rt.path)
}
}
@ -127,14 +131,14 @@ final class RelayRound(
pageHit
WithRelay(ts, rs, id) { rt =>
val sc =
if (rt.relay.sync.ongoing)
env.study.chapterRepo relaysAndTagsByStudyId rt.relay.studyId flatMap { chapters =>
if (rt.round.sync.ongoing)
env.study.chapterRepo relaysAndTagsByStudyId rt.round.studyId flatMap { chapters =>
chapters.find(_.looksAlive) orElse chapters.headOption match {
case Some(chapter) => env.study.api.byIdWithChapter(rt.relay.studyId, chapter.id)
case None => env.study.api byIdWithChapter rt.relay.studyId
case Some(chapter) => env.study.api.byIdWithChapter(rt.round.studyId, chapter.id)
case None => env.study.api byIdWithChapter rt.round.studyId
}
}
else env.study.api byIdWithChapter rt.relay.studyId
else env.study.api byIdWithChapter rt.round.studyId
sc flatMap { _ ?? { doShow(rt, _) } }
}
},
@ -142,23 +146,23 @@ final class RelayRound(
me =>
env.relay.api.byIdAndContributor(id, me) map {
case None => NotFound(jsonError("No such broadcast"))
case Some(rt) => JsonOk(env.relay.jsonView.admin(rt.relay))
case Some(rt) => JsonOk(env.relay.jsonView.admin(rt))
}
)
def chapter(ts: String, rs: String, id: String, chapterId: String) =
Open { implicit ctx =>
WithRelay(ts, rs, id) { rt =>
env.study.api.byIdWithChapter(rt.relay.studyId, chapterId) flatMap {
env.study.api.byIdWithChapter(rt.round.studyId, chapterId) flatMap {
_ ?? { doShow(rt, _) }
}
}
}
def cloneRelay(id: String) =
def cloneRound(id: String) =
Auth { implicit ctx => me =>
OptionFuResult(env.relay.api.byIdAndContributor(id, me)) { rt =>
env.relay.api.cloneRelay(rt, me) map { newRelay =>
env.relay.api.cloneRound(rt, me) map { newRelay =>
Redirect(routes.RelayRound.edit(newRelay.id.value))
}
}
@ -184,7 +188,7 @@ final class RelayRound(
)(implicit ctx: Context): Fu[Result] =
OptionFuResult(env.relay.api byIdWithTour id) { rt =>
if (rt.tour.slug != ts) Redirect(rt.path).fuccess
if (rt.relay.slug != rs) Redirect(rt.path).fuccess
if (rt.round.slug != rs) Redirect(rt.path).fuccess
else f(rt)
}
@ -206,7 +210,7 @@ final class RelayRound(
studyC.CanViewResult(oldSc.study) {
for {
(sc, studyData) <- studyC.getJsonData(oldSc)
data = env.relay.jsonView.makeData(rt.relay, studyData, ctx.userId exists sc.study.canContribute)
data = env.relay.jsonView.makeData(rt, studyData, ctx.userId exists sc.study.canContribute)
chat <- studyC.chatOf(sc.study)
sVersion <- env.study.version(sc.study.id)
streams <- studyC.streamsOf(sc.study)

View File

@ -26,7 +26,8 @@ final class RelayTour(env: Env) extends LilaController(env) {
if (tour.slug != slug) Redirect(routes.RelayTour.show(tour.slug, tour.id.value)).fuccess
else
env.relay.api.byTour(tour) map { relays =>
Ok(html.relay.tour.show(tour, relays))
val markup = tour.markup.map { md => scalatags.Text.all.raw(env.relay.markup(md)) }
Ok(html.relay.tour.show(tour, relays, markup))
}
}
}
@ -45,7 +46,7 @@ final class RelayTour(env: Env) extends LilaController(env) {
err => BadRequest(html.relay.tourForm.create(err)).fuccess,
setup =>
env.relay.api.tourCreate(setup, me) map { tour =>
Redirect(routes.RelayTour.show(tour.slug, tour.id.value))
Redirect(routes.RelayTour.show(tour.slug, tour.id.value)).flashSuccess
}
)
}
@ -53,13 +54,24 @@ final class RelayTour(env: Env) extends LilaController(env) {
def edit(id: String) = Auth { implicit ctx => me =>
WithTour(id) { tour =>
???
tour.ownedBy(me) ?? {
Ok(html.relay.tourForm.edit(tour, env.relay.tourForm.edit(tour))).fuccess
}
}
}
def update(id: String) = AuthBody { implicit ctx => me =>
WithTour(id) { tour =>
???
tour.ownedBy(me) ??
env.relay.tourForm
.edit(tour)
.bindFromRequest()(ctx.body, formBinding)
.fold(
err => BadRequest(html.relay.tourForm.edit(tour, err)).fuccess,
setup =>
env.relay.api.tourUpdate(tour, setup, me) inject
Redirect(routes.RelayTour.show(tour.slug, tour.id.value)).flashSuccess
)
}
}

View File

@ -297,8 +297,13 @@ final class Study(
def delete(id: String) =
Auth { _ => me =>
env.study.api.byIdAndOwner(id, me) flatMap {
_ ?? env.study.api.delete
} inject Redirect(routes.Study.mine("hot"))
_ ?? { study =>
env.study.api.delete(study) >> env.relay.api.deleteRound(lila.relay.RelayRound.Id(id)).map {
case None => Redirect(routes.Study.mine("hot"))
case Some(tour) => Redirect(routes.RelayTour.show(tour.slug, tour.id.value))
}
}
}
}
def clearChat(id: String) =

View File

@ -10,11 +10,7 @@ import lila.clas.{ Clas, Student }
object wall {
def show(
c: Clas,
html: Frag,
students: List[Student.WithUser]
)(implicit ctx: Context) =
def show(c: Clas, html: Frag, students: List[Student.WithUser])(implicit ctx: Context) =
teacherDashboard.layout(c, students.filter(_.student.isActive), "wall")(
div(cls := "clas-wall__actions")(
a(dataIcon := "m", href := routes.Clas.wallEdit(c.id.value), cls := "button button-clas text")(
@ -30,11 +26,7 @@ object wall {
div(cls := "box__pad clas-wall")(html)
)
def edit(
c: Clas,
students: List[Student.WithUser],
form: Form[_]
)(implicit ctx: Context) =
def edit(c: Clas, students: List[Student.WithUser], form: Form[_])(implicit ctx: Context) =
teacherDashboard.layout(c, students, "wall")(
div(cls := "box-pad clas-wall__edit")(
p(

View File

@ -0,0 +1,15 @@
package views.html.relay
import controllers.routes
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
object bits {
def howToUse(implicit ctx: Context) =
a(dataIcon := "", cls := "text", href := routes.Page.loneBookmark("broadcasts"))(
"How to use Lichess Broadcasts"
)
}

View File

@ -40,9 +40,12 @@ object index {
div(
h2(tr.tour.name),
div(cls := "relay-widget__info")(
p(tr.round.description),
if (tr.ongoing) strong(trans.playingRightNow())
else tr.round.startsAt.map(momentFromNow(_))
p(tr.tour.description),
p(
strong(tr.round.name),
if (tr.ongoing) trans.playingRightNow()
else tr.round.startsAt.map(momentFromNow(_))
)
)
)
)

View File

@ -22,21 +22,32 @@ object roundForm {
def edit(rt: RelayRound.WithTour, form: Form[Data])(implicit ctx: Context) =
layout(rt.fullName)(
h1("Edit ", rt.fullName),
inner(form, routes.RelayRound.update(rt.relay.id.value), rt.tour),
hr,
postForm(action := routes.RelayRound.cloneRelay(rt.relay.id.value))(
submitButton(
cls := "button button-empty confirm",
title := "Create an new identical broadcast, for another round or a similar tournament"
)(cloneBroadcast())
),
hr,
postForm(action := routes.RelayRound.reset(rt.relay.id.value))(
submitButton(
cls := "button button-red button-empty confirm",
title := "The source will need to be active in order to re-create the chapters!"
)(resetBroadcast())
h1("Edit ", a(href := tour.url(rt.tour))(rt.tour.name), " > ", a(href := rt.path)(rt.round.name)),
inner(form, routes.RelayRound.update(rt.round.id.value), rt.tour),
div(cls := "relay-round__actions")(
postForm(action := routes.RelayRound.cloneRound(rt.round.id.value))(
submitButton(
cls := "button button-empty confirm"
)(
strong(cloneRound()),
em("Create an new identical round for the same tournament. The games will not be included.")
)
),
postForm(action := routes.RelayRound.reset(rt.round.id.value))(
submitButton(
cls := "button button-red button-empty confirm"
)(
strong(resetRound()),
em(
"Delete all games of this round. The source will need to be active in order to re-create them."
)
)
),
postForm(action := routes.Study.delete(rt.round.id.value))(
submitButton(
cls := "button button-red button-empty confirm"
)(strong(deleteRound()), em("Definitively delete the round and its games."))
)
)
)
@ -49,27 +60,11 @@ object roundForm {
main(cls := "page-small box box-pad")(body)
)
private def inner(form: Form[Data], url: play.api.mvc.Call, tour: RelayTour)(implicit ctx: Context) =
private def inner(form: Form[Data], url: play.api.mvc.Call, t: RelayTour)(implicit ctx: Context) =
postForm(cls := "form3", action := url)(
div(cls := "form-group")(
a(dataIcon := "", cls := "text", href := routes.Page.loneBookmark("broadcasts"))(
"How to use Lichess Broadcasts"
)
),
div(cls := "form-group")(bits.howToUse),
form3.globalError(form),
form3.group(form("name"), eventName())(form3.input(_)(autofocus)),
form3.group(form("description"), eventDescription())(form3.textarea(_)(rows := 2)),
form3.group(
form("markup"),
fullDescription(),
help = fullDescriptionHelp(
a(
href := "https://guides.github.com/features/mastering-markdown/",
targetBlank
)("Markdown"),
20000.localize
).some
)(form3.textarea(_)(rows := 10)),
form3.group(form("name"), roundName())(form3.input(_)(autofocus)),
form3.group(
form("syncUrl"),
sourceUrlOrGameIds(),
@ -99,9 +94,8 @@ object roundForm {
half = true
)(form3.input(_, typ = "number"))
),
isGranted(_.Relay) option form3.group(form("credit"), credits())(form3.input(_)),
form3.actions(
a(href := views.html.relay.tour.url(tour))(trans.cancel()),
a(href := tour.url(t))(trans.cancel()),
form3.submit(trans.apply())
)
)

View File

@ -60,7 +60,7 @@ object show {
.OpenGraph(
title = rt.fullName,
url = s"$netBaseUrl${rt.path}",
description = shorten(rt.relay.description, 152)
description = shorten(rt.tour.description, 152)
)
.some
)(
@ -69,13 +69,4 @@ object show {
views.html.study.bits.streamers(streams)
)
)
def widget(rt: lila.relay.RelayRound.WithTour, extraCls: String = "") =
div(cls := s"relay-widget $extraCls", dataIcon := "")(
a(cls := "overlay", href := rt.path),
div(
h3(rt.relay.name),
p(rt.relay.description)
)
)
}

View File

@ -12,7 +12,7 @@ import lila.relay.{ RelayRound, RelayTour }
object tour {
def show(t: RelayTour, relays: List[RelayRound.WithTour])(implicit ctx: Context) =
def show(t: RelayTour, rounds: List[RelayRound.WithTour], markup: Option[Frag])(implicit ctx: Context) =
views.html.base.layout(
title = t.name,
moreCss = cssTag("relay.index")
@ -20,14 +20,40 @@ object tour {
main(cls := "relay-tour page-small box")(
div(cls := "box__top")(
h1(t.name),
a(
href := routes.RelayRound.create(t.id.value),
cls := "new button text",
dataIcon := "O"
)(trans.broadcast.addRound())
div(cls := "box__top__actions")(
a(href := routes.RelayTour.edit(t.id.value), cls := "button button-empty")(trans.edit()),
a(
href := routes.RelayRound.create(t.id.value),
cls := "button text",
dataIcon := "O"
)(trans.broadcast.addRound())
)
),
div(cls := "list")(
relays.map { views.html.relay.show.widget(_) }
standardFlash(cls := "box__pad"),
div(cls := "relay-tour__description")(markup getOrElse frag(t.description)),
div(
if (rounds.isEmpty) div(cls := "relay-tour__no-round")("No round has been scheduled yet.")
else
rounds.map { rt =>
div(
cls := List(
"relay-widget" -> true,
"relay-widget--active" -> (rt.round.startedAt.isDefined && !rt.round.finished)
),
dataIcon := ""
)(
a(cls := "overlay", href := rt.path),
div(
h2(rt.round.name),
div(
p(rt.tour.description),
if (rt.round.finished) trans.finished().some
else if (rt.round.startedAt.isDefined) strong(trans.playingRightNow()).some
else rt.round.startsAt.map(momentFromNow(_))
)
)
)
}
)
)
}

View File

@ -16,13 +16,25 @@ object tourForm {
def create(form: Form[Data])(implicit ctx: Context) =
layout(newBroadcast.txt())(
h1(newBroadcast()),
inner(form, routes.RelayTour.create)
postForm(cls := "form3", action := routes.RelayTour.create)(
inner(form),
form3.actions(
a(href := routes.RelayTour.index(1))(trans.cancel()),
form3.submit(trans.apply())
)
)
)
def edit(tour: RelayTour, form: Form[Data])(implicit ctx: Context) =
layout(tour.name)(
h1("Edit ", tour.name),
inner(form, routes.RelayTour.update(tour.id.value))
def edit(t: RelayTour, form: Form[Data])(implicit ctx: Context) =
layout(t.name)(
h1("Edit ", a(href := tour.url(t))(t.name)),
postForm(cls := "form3", action := routes.RelayTour.update(t.id.value))(
inner(form),
form3.actions(
a(href := tour.url(t))(trans.cancel()),
form3.submit(trans.apply())
)
)
)
private def layout(title: String)(body: Modifier*)(implicit ctx: Context) =
@ -33,37 +45,28 @@ object tourForm {
main(cls := "page-small box box-pad")(body)
)
private def inner(form: Form[Data], url: play.api.mvc.Call)(implicit ctx: Context) =
postForm(cls := "form3", action := url)(
div(cls := "form-group")(
a(dataIcon := "", cls := "text", href := routes.Page.loneBookmark("broadcasts"))(
"How to use Lichess Broadcasts"
)
),
form3.globalError(form),
form3.group(form("name"), eventName())(form3.input(_)(autofocus)),
form3.group(form("description"), eventDescription())(form3.textarea(_)(rows := 2)),
form3.group(
form("markup"),
fullDescription(),
help = fullDescriptionHelp(
a(
href := "https://guides.github.com/features/mastering-markdown/",
targetBlank
)("Markdown"),
20000.localize
).some
)(form3.textarea(_)(rows := 10)),
if (isGranted(_.Relay))
form3.checkbox(
form("official"),
raw("Official Lichess broadcast"),
help = raw("Feature on /broadcast - for admins only").some
)
else form3.hidden(form("official")),
form3.actions(
a(href := routes.RelayTour.index(1))(trans.cancel()),
form3.submit(trans.apply())
private def inner(form: Form[Data])(implicit ctx: Context) = frag(
div(cls := "form-group")(bits.howToUse),
form3.globalError(form),
form3.group(form("name"), tournamentName())(form3.input(_)(autofocus)),
form3.group(form("description"), tournamentDescription())(form3.textarea(_)(rows := 2)),
form3.group(
form("markup"),
fullDescription(),
help = fullDescriptionHelp(
a(
href := "https://guides.github.com/features/mastering-markdown/",
targetBlank
)("Markdown"),
20000.localize
).some
)(form3.textarea(_)(rows := 10)),
if (isGranted(_.Relay))
form3.checkbox(
form("official"),
raw("Official Lichess broadcast"),
help = raw("Feature on /broadcast - for admins only").some
)
)
else form3.hidden(form("official"))
)
}

View File

@ -20,6 +20,7 @@ db.relay.find().forEach(relay => {
syncedAt: relay.startedAt,
},
...(relay.markup ? { markup: relay.markup } : {}),
...(relay.credit ? { credit: relay.credit } : {}),
});
db.relay.update(
{ _id: relay._id },
@ -28,6 +29,9 @@ db.relay.find().forEach(relay => {
$unset: {
ownerId: true,
official: true,
description: true,
markup: true,
credit: true,
},
}
);

View File

@ -179,7 +179,7 @@ GET /broadcast/:ts/:rs/$roundId<\w{8}>/$chapterId<\w{8}> controllers.RelayRoun
GET /broadcast/round/$roundId<\w{8}>/edit controllers.RelayRound.edit(roundId: String)
POST /broadcast/round/$roundId<\w{8}>/edit controllers.RelayRound.update(roundId: String)
POST /broadcast/round/$roundId<\w{8}>/reset controllers.RelayRound.reset(roundId: String)
POST /broadcast/round/$roundId<\w{8}>/clone controllers.RelayRound.cloneRelay(roundId: String)
POST /broadcast/round/$roundId<\w{8}>/clone controllers.RelayRound.cloneRound(roundId: String)
POST /broadcast/round/$roundId<\w{8}>/push controllers.RelayRound.push(roundId: String)
# Learn

View File

@ -1396,19 +1396,21 @@ val `addRound` = new I18nKey("broadcast:addRound")
val `ongoing` = new I18nKey("broadcast:ongoing")
val `upcoming` = new I18nKey("broadcast:upcoming")
val `completed` = new I18nKey("broadcast:completed")
val `eventName` = new I18nKey("broadcast:eventName")
val `eventDescription` = new I18nKey("broadcast:eventDescription")
val `roundName` = new I18nKey("broadcast:roundName")
val `roundNumber` = new I18nKey("broadcast:roundNumber")
val `tournamentName` = new I18nKey("broadcast:tournamentName")
val `tournamentDescription` = new I18nKey("broadcast:tournamentDescription")
val `fullDescription` = new I18nKey("broadcast:fullDescription")
val `fullDescriptionHelp` = new I18nKey("broadcast:fullDescriptionHelp")
val `sourceUrlOrGameIds` = new I18nKey("broadcast:sourceUrlOrGameIds")
val `sourceUrlHelp` = new I18nKey("broadcast:sourceUrlHelp")
val `gameIdsHelp` = new I18nKey("broadcast:gameIdsHelp")
val `roundNumber` = new I18nKey("broadcast:roundNumber")
val `startDate` = new I18nKey("broadcast:startDate")
val `startDateHelp` = new I18nKey("broadcast:startDateHelp")
val `credits` = new I18nKey("broadcast:credits")
val `cloneBroadcast` = new I18nKey("broadcast:cloneBroadcast")
val `resetBroadcast` = new I18nKey("broadcast:resetBroadcast")
val `cloneRound` = new I18nKey("broadcast:cloneRound")
val `resetRound` = new I18nKey("broadcast:resetRound")
val `deleteRound` = new I18nKey("broadcast:deleteRound")
}
object streamer {

View File

@ -40,6 +40,8 @@ final class Env(
lazy val push = wire[RelayPush]
lazy val markup = wire[RelayMarkup]
private lazy val sync = wire[RelaySync]
private lazy val formatApi = wire[RelayFormatApi]

View File

@ -1,55 +1,54 @@
package lila.relay
import com.github.blemale.scaffeine.LoadingCache
import play.api.libs.json._
import scala.concurrent.duration._
import lila.common.config.BaseUrl
import lila.common.Json.jodaWrites
final class JsonView(baseUrl: BaseUrl) {
final class JsonView(baseUrl: BaseUrl, markup: RelayMarkup) {
import JsonView._
private val markdown = new lila.common.Markdown
private val markdownCache: LoadingCache[String, String] = lila.memo.CacheApi.scaffeineNoScheduler
.expireAfterAccess(10 minutes)
.maximumSize(64)
.build(markdown.apply)
implicit private val relayWrites = OWrites[RelayRound] { r =>
implicit private val roundWithTourWrites = OWrites[RelayRound.WithTour] { rt =>
Json
.obj(
"id" -> r.id,
"url" -> s"$baseUrl/broadcast/${r.slug}/${r.id}",
"name" -> r.name,
"description" -> r.description
"id" -> rt.round.id,
"url" -> s"$baseUrl${rt.path}",
"name" -> rt.round.name,
"tour" -> Json
.obj(
"id" -> rt.tour.id,
"url" -> s"$baseUrl/${rt.tour.slug}/${rt.tour.id}",
"description" -> rt.tour.description,
"active" -> rt.tour.active
)
.add("credit", rt.tour.credit)
.add("markup" -> rt.tour.markup.map(markup.apply))
)
.add("credit", r.credit)
.add("markup" -> r.markup.map(markdownCache.get))
.add("startsAt" -> r.startsAt)
.add("startedAt" -> r.startedAt)
.add("finished" -> r.finished.option(true))
.add("startsAt" -> rt.round.startsAt)
.add("startedAt" -> rt.round.startedAt)
.add("finished" -> rt.round.finished.option(true))
}
def makeData(
relay: RelayRound,
rt: RelayRound.WithTour,
studyData: lila.study.JsonView.JsData,
canContribute: Boolean
) =
JsData(
relay = if (canContribute) admin(relay) else public(relay),
relay = if (canContribute) admin(rt) else public(rt),
study = studyData.study,
analysis = studyData.analysis
)
def public(r: RelayRound) = relayWrites writes r
def public(r: RelayRound.WithTour) = roundWithTourWrites writes r
def admin(r: RelayRound) =
public(r)
.add("markdown" -> r.markup)
.add("throttle" -> r.sync.delay)
.add("sync" -> r.sync.some)
def admin(rt: RelayRound.WithTour) =
public(rt)
.add("markdown" -> rt.tour.markup)
.add("throttle" -> rt.round.sync.delay)
.add("sync" -> rt.round.sync.some)
}
object JsonView {
@ -58,7 +57,11 @@ object JsonView {
implicit val syncLogEventWrites = Json.writes[SyncLog.Event]
implicit val idWrites: Writes[RelayRound.Id] = Writes[RelayRound.Id] { id =>
implicit val roundIdWrites: Writes[RelayRound.Id] = Writes[RelayRound.Id] { id =>
JsString(id.value)
}
implicit val tourIdWrites: Writes[RelayTour.Id] = Writes[RelayTour.Id] { id =>
JsString(id.value)
}

View File

@ -134,6 +134,9 @@ final class RelayApi(
tourRepo.coll.insert.one(tour) inject tour
}
def tourUpdate(tour: RelayTour, data: RelayTourForm.Data, user: User): Funit =
tourRepo.coll.update.one($id(tour.id), data.update(tour, user)).void
def create(data: RelayRoundForm.Data, user: User, tour: RelayTour): Fu[RelayRound] = {
val relay = data.make(user, tour)
roundRepo.coll.insert.one(relay) >>
@ -163,19 +166,21 @@ final class RelayApi(
}
def update(from: RelayRound)(f: RelayRound => RelayRound): Fu[RelayRound] = {
val relay = f(from) pipe { r =>
val round = f(from) pipe { r =>
if (r.sync.upstream != from.sync.upstream) r.withSync(_.clearLog) else r
}
studyApi.rename(relay.studyId, Study.Name(relay.name)) >> {
if (relay == from) fuccess(relay)
studyApi.rename(round.studyId, Study.Name(round.name)) >> {
if (round == from) fuccess(round)
else
roundRepo.coll.update.one($id(relay.id), relay).void >> {
(relay.sync.playing != from.sync.playing) ?? publishRelay(relay)
} >>- {
relay.sync.log.events.lastOption.ifTrue(relay.sync.log != from.sync.log).foreach { event =>
sendToContributors(relay.id, "relayLog", JsonView.syncLogEventWrites writes event)
roundRepo.coll.update.one($id(round.id), round).void >> {
(round.sync.playing != from.sync.playing) ?? tourById(round.tourId).flatMap {
_.map(round.withTour).map(jsonView.admin) ?? { sendToContributors(round.id, "relayData", _) }
}
} inject relay
} >>- {
round.sync.log.events.lastOption.ifTrue(round.sync.log != from.sync.log).foreach { event =>
sendToContributors(round.id, "relayLog", JsonView.syncLogEventWrites writes event)
}
} inject round
}
}
@ -183,13 +188,20 @@ final class RelayApi(
studyApi.deleteAllChapters(relay.studyId, by) >>
requestPlay(relay.id, v = true)
def cloneRelay(rt: RelayRound.WithTour, by: User): Fu[RelayRound] =
def cloneRound(rt: RelayRound.WithTour, by: User): Fu[RelayRound] =
create(
RelayRoundForm.Data make rt.relay.copy(name = s"${rt.relay.name} (clone)"),
RelayRoundForm.Data make rt.round.copy(name = s"${rt.round.name} (clone)"),
by,
rt.tour
)
def deleteRound(roundId: RelayRound.Id): Fu[Option[RelayTour]] =
byIdWithTour(roundId) flatMap {
_ ?? { rt =>
roundRepo.coll.delete.one($id(rt.round.id)) inject rt.tour.some
}
}
def getOngoing(id: RelayRound.Id): Fu[Option[RelayRound.WithTour]] =
roundRepo.coll.one[RelayRound]($doc("_id" -> id, "finished" -> false)) flatMap {
_ ?? { relay =>
@ -243,14 +255,11 @@ final class RelayApi(
private[relay] def onStudyRemove(studyId: String) =
roundRepo.coll.delete.one($id(RelayRound.Id(studyId))).void
private[relay] def publishRelay(relay: RelayRound): Funit =
sendToContributors(relay.id, "relayData", jsonView admin relay)
private def sendToContributors(id: RelayRound.Id, t: String, msg: JsObject): Funit =
studyApi members Study.Id(id.value) map {
_.map(_.contributorIds).withFilter(_.nonEmpty) foreach { userIds =>
import lila.hub.actorApi.socket.SendTos
import JsonView.idWrites
import JsonView.roundIdWrites
import lila.socket.Socket.makeMessage
val payload = makeMessage(t, msg ++ Json.obj("id" -> id))
lila.common.Bus.publish(SendTos(userIds, payload), "socketUsers")

View File

@ -55,44 +55,44 @@ final private class RelayFetch(
lila.mon.relay.ongoing(official).update(relays.count(_.tour.official == official))
}
relays.map { rt =>
if (rt.relay.sync.ongoing) processRelay(rt) flatMap { newRelay =>
api.update(rt.relay)(_ => newRelay)
if (rt.round.sync.ongoing) processRelay(rt) flatMap { newRelay =>
api.update(rt.round)(_ => newRelay)
}
else if (rt.relay.hasStarted) {
logger.info(s"Finish by lack of activity ${rt.relay}")
api.update(rt.relay)(_.finish)
} else if (rt.relay.shouldGiveUp) {
logger.info(s"Finish for lack of start ${rt.relay}")
api.update(rt.relay)(_.finish)
} else fuccess(rt.relay)
else if (rt.round.hasStarted) {
logger.info(s"Finish by lack of activity ${rt.round}")
api.update(rt.round)(_.finish)
} else if (rt.round.shouldGiveUp) {
logger.info(s"Finish for lack of start ${rt.round}")
api.update(rt.round)(_.finish)
} else fuccess(rt.round)
}.sequenceFu addEffectAnyway scheduleNext()
}.unit
}
// no writing the relay; only reading!
def processRelay(rt: RelayRound.WithTour): Fu[RelayRound] =
if (!rt.relay.sync.playing) fuccess(rt.relay.withSync(_.play))
if (!rt.round.sync.playing) fuccess(rt.round.withSync(_.play))
else
fetchGames(rt)
.mon(_.relay.fetchTime(rt.tour.official, rt.relay.slug))
.addEffect(gs => lila.mon.relay.games(rt.tour.official, rt.relay.slug).update(gs.size).unit)
.mon(_.relay.fetchTime(rt.tour.official, rt.round.slug))
.addEffect(gs => lila.mon.relay.games(rt.tour.official, rt.round.slug).update(gs.size).unit)
.flatMap { games =>
sync(rt, games)
.withTimeout(7 seconds, SyncResult.Timeout)
.mon(_.relay.syncTime(rt.tour.official, rt.relay.slug))
.mon(_.relay.syncTime(rt.tour.official, rt.round.slug))
.map { res =>
res -> rt.relay.withSync(_ addLog SyncLog.event(res.moves, none))
res -> rt.round.withSync(_ addLog SyncLog.event(res.moves, none))
}
}
.recover { case e: Exception =>
(e match {
case SyncResult.Timeout =>
if (rt.tour.official) logger.info(s"Sync timeout ${rt.relay}")
if (rt.tour.official) logger.info(s"Sync timeout ${rt.round}")
SyncResult.Timeout
case _ =>
if (rt.tour.official) logger.info(s"Sync error ${rt.relay} ${e.getMessage take 80}")
if (rt.tour.official) logger.info(s"Sync error ${rt.round} ${e.getMessage take 80}")
SyncResult.Error(e.getMessage)
}) -> rt.relay.withSync(_ addLog SyncLog.event(0, e.some))
}) -> rt.round.withSync(_ addLog SyncLog.event(0, e.some))
}
.map { case (result, newRelay) =>
afterSync(result, newRelay withTour rt.tour)
@ -102,30 +102,30 @@ final private class RelayFetch(
result match {
case SyncResult.Ok(0, _) => continueRelay(rt)
case SyncResult.Ok(nbMoves, _) =>
lila.mon.relay.moves(rt.tour.official, rt.relay.slug).increment(nbMoves)
continueRelay(rt.relay.ensureStarted.resume withTour rt.tour)
lila.mon.relay.moves(rt.tour.official, rt.round.slug).increment(nbMoves)
continueRelay(rt.round.ensureStarted.resume withTour rt.tour)
case _ => continueRelay(rt)
}
def continueRelay(rt: RelayRound.WithTour): RelayRound =
rt.relay.sync.upstream.fold(rt.relay) { upstream =>
rt.round.sync.upstream.fold(rt.round) { upstream =>
val seconds =
if (rt.relay.sync.log.alwaysFails && !upstream.local) {
rt.relay.sync.log.events.lastOption
if (rt.round.sync.log.alwaysFails && !upstream.local) {
rt.round.sync.log.events.lastOption
.filterNot(_.isTimeout)
.flatMap(_.error)
.ifTrue(rt.tour.official && rt.relay.hasStarted) foreach { error =>
slackApi.broadcastError(rt.relay.id.value, rt.relay.name, error)
.ifTrue(rt.tour.official && rt.round.hasStarted) foreach { error =>
slackApi.broadcastError(rt.round.id.value, rt.round.name, error)
}
60
} else
rt.relay.sync.delay getOrElse {
rt.round.sync.delay getOrElse {
if (upstream.local) 3 else 6
}
rt.relay.withSync {
rt.round.withSync {
_.copy(
nextAt = DateTime.now plusSeconds {
seconds atLeast { if (rt.relay.sync.log.justTimedOut) 10 else 2 }
seconds atLeast { if (rt.round.sync.log.justTimedOut) 10 else 2 }
} some
)
}
@ -146,7 +146,7 @@ final private class RelayFetch(
)
private def fetchGames(rt: RelayRound.WithTour): Fu[RelayGames] =
rt.relay.sync.upstream ?? {
rt.round.sync.upstream ?? {
case UpstreamIds(ids) =>
gameRepo.gamesFromSecondary(ids) flatMap
gameProxy.upgradeIfPresent flatMap
@ -161,10 +161,10 @@ final private class RelayFetch(
url,
(_, v) =>
Option(v) match {
case Some(GamesSeenBy(games, seenBy)) if !seenBy(rt.relay.id) =>
GamesSeenBy(games, seenBy + rt.relay.id)
case Some(GamesSeenBy(games, seenBy)) if !seenBy(rt.round.id) =>
GamesSeenBy(games, seenBy + rt.round.id)
case _ =>
GamesSeenBy(doFetchUrl(url, RelayFetch.maxChapters(rt.tour)), Set(rt.relay.id))
GamesSeenBy(doFetchUrl(url, RelayFetch.maxChapters(rt.tour)), Set(rt.round.id))
}
)
.games

View File

@ -0,0 +1,17 @@
package lila.relay
import com.github.blemale.scaffeine.LoadingCache
import scala.concurrent.duration._
final class RelayMarkup {
private val renderer = new lila.common.Markdown(autoLink = true)
private val cache: LoadingCache[String, String] = lila.memo.CacheApi.scaffeineNoScheduler
.expireAfterAccess(20 minutes)
.maximumSize(256)
.build(renderer.apply)
def apply(text: String): String = cache.get(text)
}

View File

@ -14,12 +14,12 @@ final class RelayPush(sync: RelaySync, api: RelayApi)(implicit
private val throttler = system.actorOf(Props(new EarlyMultiThrottler(logger = logger)))
def apply(rt: RelayRound.WithTour, pgn: String): Fu[Option[String]] =
if (rt.relay.sync.hasUpstream)
if (rt.round.sync.hasUpstream)
fuccess("The relay has an upstream URL, and cannot be pushed to.".some)
else
fuccess {
throttler ! EarlyMultiThrottler.Work(
id = rt.relay.id.value,
id = rt.round.id.value,
run = () => pushNow(rt, pgn),
cooldown = if (rt.tour.official) 3.seconds else 7.seconds
)
@ -39,6 +39,6 @@ final class RelayPush(sync: RelaySync, api: RelayApi)(implicit
SyncLog.event(0, e.some)
}
.flatMap { event =>
api.update(rt.relay)(_.withSync(_ addLog event)).void
api.update(rt.round)(_.withSync(_ addLog event)).void
}
}

View File

@ -9,9 +9,6 @@ case class RelayRound(
_id: RelayRound.Id,
tourId: RelayTour.Id,
name: String,
description: String,
markup: Option[String] = None,
credit: Option[String] = None,
sync: RelayRound.Sync,
/* When it's planned to start */
startsAt: Option[DateTime],
@ -61,8 +58,6 @@ case class RelayRound(
def withTour(tour: RelayTour) = RelayRound.WithTour(this, tour)
def showStartAt = startsAt orElse startedAt
override def toString = s"""relay #$id "$name" $sync"""
}
@ -135,12 +130,12 @@ object RelayRound {
case class UpstreamIds(ids: List[lila.game.Game.ID]) extends Upstream
}
case class WithTour(relay: RelayRound, tour: RelayTour) {
def fullName = s"${tour.name}${relay.name}"
case class WithTour(round: RelayRound, tour: RelayTour) {
def fullName = s"${tour.name}${round.name}"
def withStudy(study: Study) = WithTourAndStudy(relay, tour, study)
def withStudy(study: Study) = WithTourAndStudy(round, tour, study)
def path: String = s"/broadcast/${tour.slug}/${relay.slug}/${relay.id}"
def path: String = s"/broadcast/${tour.slug}/${round.slug}/${round.id}"
def path(chapterId: Chapter.Id): String = s"$path/$chapterId"
}

View File

@ -19,14 +19,11 @@ final class RelayRoundForm {
val roundMapping =
mapping(
"name" -> cleanText(minLength = 3, maxLength = 80),
"description" -> cleanText(minLength = 3, maxLength = 400),
"markup" -> optional(cleanText(maxLength = 20000)),
"name" -> cleanText(minLength = 3, maxLength = 80),
"syncUrl" -> optional {
cleanText(minLength = 8, maxLength = 600).verifying("Invalid source", validSource _)
},
"syncUrlRound" -> optional(number(min = 1, max = 999)),
"credit" -> optional(cleanNonEmptyText),
"startsAt" -> optional(ISODateTimeOrTimestamp.isoDateTimeOrTimestamp),
"throttle" -> optional(number(min = 2, max = 60))
)(Data.apply)(Data.unapply)
@ -38,12 +35,7 @@ final class RelayRoundForm {
s"Maximum rounds per tournament: ${RelayTour.maxRelays}",
_ => trs.rounds.sizeIs < RelayTour.maxRelays
)
}.fill(
Data(
name = s"Round ${trs.rounds.size + 1}",
description = ""
)
)
}.fill(Data(name = s"Round ${trs.rounds.size + 1}"))
def edit(r: RelayRound) = Form(roundMapping) fill Data.make(r)
}
@ -93,11 +85,8 @@ object RelayRoundForm {
case class Data(
name: String,
description: String,
markup: Option[String] = None,
syncUrl: Option[String] = None,
syncUrlRound: Option[Int] = None,
credit: Option[String] = None,
startsAt: Option[DateTime] = None,
throttle: Option[Int] = None
) {
@ -116,12 +105,9 @@ object RelayRoundForm {
def update(relay: RelayRound, user: User) =
relay.copy(
name = name,
description = description,
markup = markup,
sync = makeSync(user) pipe { sync =>
if (relay.sync.playing) sync.play else sync
},
credit = credit,
startsAt = startsAt,
finished = relay.finished && startsAt.fold(true)(_.isBeforeNow)
)
@ -144,10 +130,7 @@ object RelayRoundForm {
_id = RelayRound.makeId,
tourId = tour.id,
name = name,
description = description,
markup = markup,
sync = makeSync(user),
credit = credit,
createdAt = DateTime.now,
finished = false,
startsAt = startsAt,
@ -160,14 +143,11 @@ object RelayRoundForm {
def make(relay: RelayRound) =
Data(
name = relay.name,
description = relay.description,
markup = relay.markup,
syncUrl = relay.sync.upstream map {
case url: RelayRound.Sync.UpstreamUrl => url.withRound.url
case RelayRound.Sync.UpstreamIds(ids) => ids mkString " "
},
syncUrlRound = relay.sync.upstream.flatMap(_.asUrl).flatMap(_.withRound.round),
credit = relay.credit,
startsAt = relay.startsAt,
throttle = relay.sync.delay
)

View File

@ -14,7 +14,7 @@ final private class RelaySync(
private type NbMoves = Int
def apply(rt: RelayRound.WithTour, games: RelayGames): Fu[SyncResult.Ok] =
studyApi byId rt.relay.studyId orFail "Missing relay study!" flatMap { study =>
studyApi byId rt.round.studyId orFail "Missing relay study!" flatMap { study =>
chapterRepo orderedByStudy study.id flatMap { chapters =>
RelayInputSanity(chapters, games) match {
case Some(fail) => fufail(fail.msg)

View File

@ -9,6 +9,7 @@ case class RelayTour(
name: String,
description: String,
markup: Option[String] = None,
credit: Option[String] = None,
ownerId: User.ID,
createdAt: DateTime,
official: Boolean,
@ -23,6 +24,8 @@ case class RelayTour(
}
def withRounds(rounds: List[RelayRound]) = RelayTour.WithRounds(this, rounds)
def ownedBy(user: User) = ownerId == user.id
}
object RelayTour {

View File

@ -27,7 +27,7 @@ final class RelayTourForm {
def create = form
def edit(r: RelayTour) = form fill Data.make(r)
def edit(t: RelayTour) = form fill Data.make(t)
}
object RelayTourForm {

View File

@ -7,17 +7,19 @@
<string name="ongoing">Ongoing</string>
<string name="upcoming">Upcoming</string>
<string name="completed">Completed</string>
<string name="eventName">Event name</string>
<string name="eventDescription">Short event description</string>
<string name="fullDescription">Full event description</string>
<string name="fullDescriptionHelp">Optional long description of the broadcast. %1$s is available. Length must be less than %2$s characters.</string>
<string name="roundName">Round name</string>
<string name="roundNumber">Round number</string>
<string name="tournamentName">Tournament name</string>
<string name="tournamentDescription">Short tournament description</string>
<string name="fullDescription">Full tournament description</string>
<string name="fullDescriptionHelp">Optional long description of the tournament. %1$s is available. Length must be less than %2$s characters.</string>
<string name="sourceUrlOrGameIds">Source URL, or game IDs</string>
<string name="sourceUrlHelp">URL that Lichess will check to get PGN updates. It must be publicly accessible from the Internet.</string>
<string name="gameIdsHelp">Alternatively, you can enter up to 64 Lichess game IDs, separated by spaces.</string>
<string name="roundNumber">Round number</string>
<string name="startDate">Start date in your own timezone</string>
<string name="startDateHelp">Optional, if you know when the event starts</string>
<string name="credits">Credit the source</string>
<string name="cloneBroadcast">Clone the broadcast</string>
<string name="resetBroadcast">Reset the broadcast</string>
<string name="cloneRound">Clone this round</string>
<string name="resetRound">Reset this round</string>
<string name="deleteRound">Delete this round</string>
</resources>

View File

@ -244,7 +244,7 @@ export function view(ctrl: StudyFormCtrl): VNode {
? h(
'a',
{
attrs: { href: `/broadcast/-/${data.id}/edit` },
attrs: { href: `/broadcast/round/${data.id}/edit` },
},
'Broadcast settings'
)

View File

@ -2,3 +2,4 @@
@import '../../../common/css/form/form3';
@import '../../../common/css/form/cmn-toggle';
@import '../../../common/css/vendor/flatpickr';
@import '../relay/form';

View File

@ -1,4 +1,4 @@
@import '../../../common/css/plugin';
@import '../../../common/css/form/form3';
@import '../../../common/css/form/captcha';
@import '../relay/index';
@import '../../../common/css/component/slist';
@import '../relay/relay';

View File

@ -0,0 +1,21 @@
.relay-round__actions {
margin: 2em 0;
border-top: $border;
padding-top: 3em;
form {
.button {
@extend %flex-column;
text-align: left;
width: 100%;
strong {
display: block;
}
em {
color: $c-font-dim;
text-transform: none;
white-space: normal;
}
}
}
}

View File

@ -1,15 +0,0 @@
@import 'widget';
.relay-index {
.box__top {
flex-flow: row nowrap;
}
.new::before {
font-size: 3em;
}
section {
margin-bottom: 2em;
}
}

View File

@ -0,0 +1,32 @@
@import 'widget';
.relay-index {
.box__top {
flex-flow: row nowrap;
}
.new::before {
font-size: 3em;
}
section {
margin-bottom: 2em;
}
}
.relay-tour {
&__description {
@extend %box-padding, %box-neat;
margin: 2em auto 2em auto;
padding: 1.5em 2.5em;
background: $c-bg-zebra;
width: 70%;
}
&__no-round {
@extend %box-padding;
margin: 3em;
text-align: center;
}
}