video library WIP
parent
0f9f3c8f8f
commit
da16a6d9ed
|
@ -7,24 +7,46 @@ import play.twirl.api.Html
|
|||
import lila.api.Context
|
||||
import lila.app._
|
||||
import lila.common.HTTPRequest
|
||||
import lila.video.{ View, Video => VideoModel }
|
||||
import lila.video.{ View, Video => VideoModel, UserControl }
|
||||
import views._
|
||||
|
||||
object Video extends LilaController {
|
||||
|
||||
private def env = Env.video
|
||||
|
||||
private def UserControl[A](f: UserControl => Fu[A])(implicit ctx: Context): Fu[A] =
|
||||
env.api.userControl(ctx.userId | "anon", 25) flatMap f
|
||||
|
||||
private def renderIndex(control: UserControl)(implicit ctx: Context) =
|
||||
env.api.video.byTags(control.filter.tags, 15) map { videos =>
|
||||
Ok(html.video.index(videos, control))
|
||||
}
|
||||
|
||||
def index = Open { implicit ctx =>
|
||||
env.api.video.popular(9) map { videos =>
|
||||
html.video.index(videos)
|
||||
UserControl { control =>
|
||||
val filter = control.filter.copy(
|
||||
tags = get("tags") ?? (_.split(',').toList.map(_.trim.toLowerCase))
|
||||
)
|
||||
env.api.filter.set(filter) >>
|
||||
renderIndex(control.copy(filter = filter))
|
||||
}
|
||||
}
|
||||
|
||||
def show(id: String) = Open { implicit ctx =>
|
||||
env.api.video.find(id) flatMap {
|
||||
case None => fuccess(html.video.notFound())
|
||||
case Some(video) => env.api.video.similar(video, 6) map { similar =>
|
||||
html.video.show(video, similar)
|
||||
UserControl { control =>
|
||||
env.api.video.find(id) flatMap {
|
||||
case None => fuccess(NotFound(html.video.notFound(control)))
|
||||
case Some(video) => env.api.video.similar(video, 9) map { similar =>
|
||||
Ok(html.video.show(video, similar, control))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def author(author: String) = Open { implicit ctx =>
|
||||
UserControl { control =>
|
||||
env.api.video byAuthor author map { videos =>
|
||||
Ok(html.video.author(author, videos, control))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
@(author: String, videos: List[lila.video.Video], control: lila.video.UserControl)(implicit ctx: Context)
|
||||
|
||||
@layout(
|
||||
title = s"$author • Free Chess Videos",
|
||||
control = control) {
|
||||
|
||||
<div class="content_box_top">
|
||||
<a class="is4 text lichess_title" data-icon="i" href="@routes.Video.index"></a>
|
||||
<h1 class="lichess_title">
|
||||
@author • @pluralize("video", videos.size) found
|
||||
</h1>
|
||||
</div>
|
||||
<div class="list">
|
||||
@videos.map { video =>
|
||||
@card(video)
|
||||
}
|
||||
</div>
|
||||
}
|
|
@ -1,18 +1,33 @@
|
|||
@(videos: List[lila.video.Video])(implicit ctx: Context)
|
||||
|
||||
@side = {
|
||||
}
|
||||
@(videos: List[lila.video.Video], control: lila.video.UserControl)(implicit ctx: Context)
|
||||
|
||||
@layout(
|
||||
title = "Free Chess Videos",
|
||||
side = side.some) {
|
||||
control = control) {
|
||||
|
||||
<div class="content_box_top">
|
||||
<h1 data-icon="@openingBrace" class="is4 text lichess_title">Video library</h1>
|
||||
<h1 data-icon="@openingBrace" class="is4 text lichess_title">
|
||||
@if(control.filter.tags.nonEmpty) {
|
||||
@pluralize("video", videos.size) found
|
||||
} else {
|
||||
Video library
|
||||
}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="list">
|
||||
@videos.map { video =>
|
||||
@card(video)
|
||||
}
|
||||
@if(videos.size < 4) {
|
||||
<div class="not_much nb_@videos.size">
|
||||
@if(videos.isEmpty) {
|
||||
No videos for these tags!
|
||||
} else {
|
||||
That's all we got for these tags!
|
||||
}
|
||||
<br />
|
||||
<br />
|
||||
<a href="@routes.Video.index" class="button">Clear search</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
@(title: String, side: Option[Html] = None)(body: Html)(implicit ctx: Context)
|
||||
@(title: String, control: lila.video.UserControl)(body: Html)(implicit ctx: Context)
|
||||
|
||||
@sideSection = {
|
||||
<div class="side">
|
||||
@side
|
||||
<div id="video_side">
|
||||
<div class="tag_list">
|
||||
@control.tags.map { t =>
|
||||
<a class="@if(control.filter.tags contains t.tag) {checked}"
|
||||
href="@routes.Video.index?tags=@control.filter.toggle(t.tag).tagString">
|
||||
<em>@t.nb</em>
|
||||
@t.tag.capitalize
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
@()(implicit ctx: Context)
|
||||
@(control: lila.video.UserControl)(implicit ctx: Context)
|
||||
|
||||
@layout(title = "Video not found") {
|
||||
@layout(
|
||||
title = "Video not found",
|
||||
control = control) {
|
||||
|
||||
<div class="content_box_top">
|
||||
<h1 data-icon="@openingBrace" class="is4 text lichess_title">
|
||||
<a href="@routes.Video.index">Video library</a>
|
||||
</h1>
|
||||
<a class="is4 text lichess_title" data-icon="i" href="@routes.Video.index">Video library</a>
|
||||
</div>
|
||||
<div class="not_found">
|
||||
<h2>Video Not Found!</h2>
|
||||
<a href="@routes.Video.index">View all videos</a>
|
||||
<h1>Video Not Found!</h1>
|
||||
<br />
|
||||
<br />
|
||||
<a class="big button text" data-icon="i" href="@routes.Video.index">Return to the video library</a>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
@(video: lila.video.Video, similar: List[lila.video.Video])(implicit ctx: Context)
|
||||
@(video: lila.video.Video, similar: List[lila.video.Video], control: lila.video.UserControl)(implicit ctx: Context)
|
||||
|
||||
@layout(title = s"${video.title} • Free Chess Videos") {
|
||||
@layout(
|
||||
title = s"${video.title} • Free Chess Videos",
|
||||
control = control) {
|
||||
|
||||
<div class="show">
|
||||
<div class="content_box_top">
|
||||
<a class="is4 text lichess_title" data-icon="i" href="@routes.Video.index">Video library</a>
|
||||
</div>
|
||||
<iframe id="ytplayer" type="text/html" width="792" height="482"
|
||||
src="http://www.youtube.com/embed/@video.id?autoplay=0&origin=http://lichess.org"
|
||||
src="http://www.youtube.com/embed/@video.id?autoplay=1&origin=http://lichess.org"
|
||||
frameborder="0"></iframe>
|
||||
<h1 class="title">@video.title</h1>
|
||||
<div class="meta">
|
||||
<a class="author" href="#">@video.author</a>
|
||||
<a class="author" href="@routes.Video.author(video.author)">@video.author</a>
|
||||
@video.tags.map { tag =>
|
||||
<a class="tag" data-icon="o" href="#">@tag.capitalize</a>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
@(tags: List[String])(implicit ctx: Context)
|
||||
|
||||
|
|
@ -183,6 +183,7 @@ GET /setup/validate-fen controllers.Setup.validateFen
|
|||
|
||||
# Video
|
||||
GET /video controllers.Video.index
|
||||
GET /video/author/:author controllers.Video.author(author: String)
|
||||
GET /video/:id controllers.Video.show(id: String)
|
||||
|
||||
# I18n
|
||||
|
|
|
@ -14,6 +14,7 @@ final class Env(
|
|||
private val settings = new {
|
||||
val CollectionVideo = config getString "collection.video"
|
||||
val CollectionView = config getString "collection.view"
|
||||
val CollectionFilter = config getString "collection.filter"
|
||||
val SheetUrl = config getString "sheet.url"
|
||||
val SheetDelay = config duration "sheet.delay"
|
||||
val YoutubeUrl = config getString "youtube.url"
|
||||
|
@ -25,7 +26,8 @@ final class Env(
|
|||
|
||||
lazy val api = new VideoApi(
|
||||
videoColl = videoColl,
|
||||
viewColl = viewColl)
|
||||
viewColl = viewColl,
|
||||
filterColl = filterColl)
|
||||
|
||||
private lazy val sheet = new Sheet(
|
||||
url = SheetUrl,
|
||||
|
@ -46,11 +48,12 @@ final class Env(
|
|||
}
|
||||
|
||||
scheduler.once(10 seconds) {
|
||||
sheet.fetchAll >> youtube.updateAll logFailure "video boot"
|
||||
// sheet.fetchAll >> youtube.updateAll logFailure "video boot"
|
||||
}
|
||||
|
||||
private[video] lazy val videoColl = db(CollectionVideo)
|
||||
private[video] lazy val viewColl = db(CollectionView)
|
||||
private[video] lazy val filterColl = db(CollectionFilter)
|
||||
}
|
||||
|
||||
object Env {
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
package lila.video
|
||||
|
||||
import org.joda.time.DateTime
|
||||
|
||||
case class TagNb(_id: Tag, nb: Int) {
|
||||
|
||||
def tag = _id
|
||||
|
||||
def noSpace = tag.replace(" ", "-")
|
||||
}
|
||||
|
||||
case class Filter(
|
||||
_id: String, // user ID
|
||||
tags: List[String],
|
||||
date: DateTime) {
|
||||
|
||||
def id = _id
|
||||
|
||||
def toggle(tag: String) = copy(
|
||||
tags = if (tags contains tag) tags filter (tag!=) else tags :+ tag
|
||||
)
|
||||
|
||||
def tagString = tags mkString ","
|
||||
}
|
||||
|
||||
case class UserControl(
|
||||
filter: Filter,
|
||||
tags: List[TagNb])
|
|
@ -3,13 +3,16 @@ package lila.video
|
|||
import org.joda.time.DateTime
|
||||
import reactivemongo.bson._
|
||||
import reactivemongo.core.commands._
|
||||
import scala.concurrent.duration._
|
||||
import spray.caching.{ LruCache, Cache }
|
||||
|
||||
import lila.db.Types.Coll
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
private[video] final class VideoApi(
|
||||
videoColl: Coll,
|
||||
viewColl: Coll) {
|
||||
viewColl: Coll,
|
||||
filterColl: Coll) {
|
||||
|
||||
import lila.db.BSON.BSONJodaDateTimeHandler
|
||||
import reactivemongo.bson.Macros
|
||||
|
@ -18,6 +21,8 @@ private[video] final class VideoApi(
|
|||
Macros.handler[Metadata]
|
||||
}
|
||||
private implicit val VideoBSONHandler = Macros.handler[Video]
|
||||
private implicit val TagNbBSONHandler = Macros.handler[TagNb]
|
||||
private implicit val FilterBSONHandler = Macros.handler[Filter]
|
||||
import View.viewBSONHandler
|
||||
|
||||
object video {
|
||||
|
@ -28,7 +33,7 @@ private[video] final class VideoApi(
|
|||
def save(video: Video): Funit =
|
||||
videoColl.update(
|
||||
BSONDocument("_id" -> video.id),
|
||||
video,
|
||||
BSONDocument("$set" -> video),
|
||||
upsert = true).void
|
||||
|
||||
def removeNotIn(ids: List[Video.ID]) =
|
||||
|
@ -57,12 +62,28 @@ private[video] final class VideoApi(
|
|||
.cursor[Video]
|
||||
.collect[List](max)
|
||||
|
||||
def byTags(tags: List[Tag], max: Int): Fu[List[Video]] =
|
||||
if (tags.isEmpty) popular(max)
|
||||
else videoColl.find(BSONDocument(
|
||||
"tags" -> BSONDocument("$all" -> tags)
|
||||
)).sort(BSONDocument(
|
||||
"metadata.likes" -> -1
|
||||
)).cursor[Video]
|
||||
.collect[List]()
|
||||
|
||||
def byAuthor(author: String): Fu[List[Video]] =
|
||||
videoColl.find(BSONDocument(
|
||||
"author" -> author
|
||||
)).sort(BSONDocument(
|
||||
"metadata.likes" -> -1
|
||||
)).cursor[Video]
|
||||
.collect[List]()
|
||||
|
||||
def similar(video: Video, max: Int): Fu[List[Video]] =
|
||||
videoColl.find(BSONDocument(
|
||||
"tags" -> BSONDocument("$in" -> video.tags),
|
||||
"_id" -> BSONDocument("$ne" -> video.id)
|
||||
))
|
||||
.sort(BSONDocument("metadata.likes" -> -1))
|
||||
)).sort(BSONDocument("metadata.likes" -> -1))
|
||||
.cursor[Video]
|
||||
.collect[List]().map { videos =>
|
||||
videos.sortBy { v => -v.similarity(video) } take max
|
||||
|
@ -83,4 +104,44 @@ private[video] final class VideoApi(
|
|||
View.BSONFields.id -> View.makeId(video.id, user.id)
|
||||
).some) map (0!=)
|
||||
}
|
||||
|
||||
object tag {
|
||||
|
||||
private val cache: Cache[List[TagNb]] = LruCache(timeToLive = 1.day)
|
||||
|
||||
def clearCache = fuccess(cache.clear)
|
||||
|
||||
def popular(max: Int): Fu[List[TagNb]] = cache(max) {
|
||||
import reactivemongo.core.commands._
|
||||
val command = Aggregate(videoColl.name, Seq(
|
||||
Project("tags" -> BSONBoolean(true)),
|
||||
Unwind("tags"),
|
||||
GroupField("tags")("nb" -> SumValue(1)),
|
||||
Sort(Seq(Descending("nb"))),
|
||||
Limit(max)
|
||||
))
|
||||
videoColl.db.command(command) map {
|
||||
_.toList.flatMap(_.asOpt[TagNb])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object filter {
|
||||
|
||||
def get(userId: String) =
|
||||
filterColl.find(
|
||||
BSONDocument("_id" -> userId)
|
||||
).one[Filter] map (_ | Filter(userId, Nil, DateTime.now))
|
||||
|
||||
def set(filter: Filter) =
|
||||
filterColl.update(
|
||||
BSONDocument("_id" -> filter.id),
|
||||
BSONDocument("$set" -> filter.copy(
|
||||
date = DateTime.now
|
||||
)),
|
||||
upsert = true).void
|
||||
}
|
||||
|
||||
def userControl(userId: String, maxTags: Int): Fu[UserControl] =
|
||||
filter.get(userId) zip tag.popular(maxTags) map (UserControl.apply _).tupled
|
||||
}
|
||||
|
|
|
@ -91,6 +91,9 @@ time {
|
|||
[data-icon].is4::before {
|
||||
font-size: 30px;
|
||||
}
|
||||
.is4.text::before {
|
||||
margin-right: 10px;
|
||||
}
|
||||
[data-icon].is-green::before {
|
||||
color: #759900;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
#video {
|
||||
min-height: 823px;
|
||||
}
|
||||
#video a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
@ -5,6 +8,9 @@
|
|||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
#video .content_box_top .lichess_title {
|
||||
display: inline;
|
||||
}
|
||||
#video .list {
|
||||
overflow: hidden;
|
||||
padding: 8px 0 8px 8px;
|
||||
|
@ -73,7 +79,6 @@
|
|||
#video .card .tags span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#video .show .meta {
|
||||
padding: 0 25px 10px 25px;
|
||||
}
|
||||
|
@ -94,3 +99,43 @@
|
|||
#video .show .tag:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
#video_side {
|
||||
margin-top: 20px;
|
||||
margin-left: -30px;
|
||||
width: 230px;
|
||||
}
|
||||
#video_side .tag_list {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
#video_side .tag_list a {
|
||||
display: block;
|
||||
padding: 5px;
|
||||
text-decoration: none;
|
||||
border-left: 2px solid transparent;
|
||||
transition: 0.13s;
|
||||
}
|
||||
#video_side .tag_list a:hover {
|
||||
background: #f8f8f8;
|
||||
border-color: #ccc;
|
||||
}
|
||||
#video_side .tag_list em {
|
||||
float: right;
|
||||
font-weight: bold;
|
||||
opacity: 0.7;
|
||||
}
|
||||
#video_side .tag_list a.checked {
|
||||
background: #fff;
|
||||
border-color: #d85000;
|
||||
}
|
||||
|
||||
#video .not_found {
|
||||
margin-top: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
#video .not_much {
|
||||
margin-top: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
#video .not_much.nb_0 {
|
||||
margin-top: 200px;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue