fix up movies index route [wip]

This commit is contained in:
ducklet 2021-08-04 01:04:13 +02:00
parent 7cc540b6fd
commit bae0415a24
4 changed files with 210 additions and 8 deletions

View file

@ -29,8 +29,8 @@
<span class="mediatype">{{ item.media_type }}</span>
</td>
<td>
<span class="score imdb-score tag is-large" title="IMDb rating (1-10)">{{ imdb_rating(item.imdb_score) }}</span>
<span class="score tag is-info is-large" title="User rating (1-10)">{{ imdb_rating(item.user_score) }}</span>
<span class="score imdb-score tag is-large" :title="`IMDb rating (1-10) / ${item.imdb_votes} votes`">{{ imdb_rating(item.imdb_score) }}</span>
<span class="score tag is-info is-large" :title="`User rating (1-10) / ${item.user_scores.length} votes / σ = ${imdb_stdev(item.user_scores)}`">{{ avg_imdb_rating(item.user_scores) }}</span>
</td>
<td>
<span>{{ duration(item.runtime) }}</span>
@ -44,6 +44,20 @@
<script lang="ts">
import { defineComponent } from "vue"
import { mean, pstdev } from "../utils.ts"
function avg_imdb_rating(scores) {
return imdb_rating(scores.length === 0 ? null : mean(scores))
}
function imdb_stdev(scores) {
return pstdev(scores.map(imdb_rating_from_score))
}
function imdb_rating_from_score(score) {
return Math.round((score * 9) / 100 + 1)
}
function imdb_rating(score) {
if (score == null) {
@ -85,7 +99,9 @@ export default defineComponent({
observer.observe(this.$refs.sentinel)
},
methods: {
avg_imdb_rating,
imdb_rating,
imdb_stdev,
duration,
},
})

View file

@ -1,3 +1,18 @@
export function is_object(x) {
return x !== null && typeof x === "object" && !Array.isArray(x)
}
export function sum(nums) {
return nums.reduce((s, n) => s + n, 0)
}
export function mean(nums) {
return sum(nums) / nums.length
}
export function pstdev(nums, mu=null) {
if (mu === null) {
mu = mean(nums)
}
return Math.sqrt(mean(nums.map((n) => (n - mu) ** 2)))
}

View file

@ -459,6 +459,126 @@ async def find_ratings(
return tuple(dict(r) for r in rows)
def sql_fields(tp: Type):
return (f"{tp._table}.{f.name}" for f in fields(tp))
def sql_fieldmap(tp: Type):
"""-> {alias: (table, field_name)}"""
return {f"{tp._table}_{f.name}": (tp._table, f.name) for f in fields(tp)}
def mux(*tps: Type):
return ", ".join(
f"{t}.{n} AS {k}" for tp in tps for k, (t, n) in sql_fieldmap(tp).items()
)
def demux(tp: Type[ModelType], row) -> ModelType:
return fromplain(tp, {n: row[k] for k, (_, n) in sql_fieldmap(tp).items()})
async def find_movies(
*,
title: str = None,
media_type: str = None,
exact: bool = False,
ignore_tv_episodes: bool = False,
yearcomp: tuple[Literal["<", "=", ">"], int] = None,
limit_rows: int = 10,
skip_rows: int = 0,
include_unrated: bool = False,
user_ids: list[ULID] = [],
) -> Iterable[tuple[Movie, list[Rating]]]:
values: dict[str, Union[int, str]] = {
"limit_rows": limit_rows,
"skip_rows": skip_rows,
}
conditions = []
if title:
values["escape"] = "#"
escaped_title = sql_escape(title, char=values["escape"])
values["pattern"] = (
"_".join(escaped_title.split())
if exact
else "%" + "%".join(escaped_title.split()) + "%"
)
conditions.append(
f"""
(
{Movie._table}.title LIKE :pattern ESCAPE :escape
OR {Movie._table}.original_title LIKE :pattern ESCAPE :escape
)
"""
)
if yearcomp:
op, year = yearcomp
assert op in "<=>"
values["year"] = year
conditions.append(f"{Movie._table}.release_year{op}:year")
if media_type:
values["media_type"] = media_type
conditions.append(f"{Movie._table}.media_type=:media_type")
if ignore_tv_episodes:
conditions.append(f"{Movie._table}.media_type!='TV Episode'")
if not include_unrated:
conditions.append(f"{Movie._table}.score NOTNULL")
if not user_ids:
query = f"""
SELECT {','.join(sql_fields(Movie))}
FROM {Movie._table}
WHERE {(' AND '.join(conditions)) if conditions else '1=1'}
ORDER BY
length({Movie._table}.title) ASC,
{Movie._table}.imdb_score DESC,
{Movie._table}.release_year DESC
LIMIT :skip_rows, :limit_rows
"""
rows = await shared_connection().fetch_all(bindparams(query, values))
return ((fromplain(Movie, row), []) for row in rows)
# XXX add user_ids filtering
fields_ = mux(Movie, Rating)
query = f"""
WITH movie_ids AS (
SELECT id
FROM {Movie._table}
WHERE {(' AND '.join(conditions)) if conditions else '1=1'}
ORDER BY
length({Movie._table}.title) ASC,
{Movie._table}.imdb_score DESC,
{Movie._table}.release_year DESC
LIMIT :skip_rows, :limit_rows
)
SELECT {fields_}
FROM {Movie._table}
LEFT JOIN {Rating._table} ON {Rating._table}.movie_id={Movie._table}.id
WHERE {Movie._table}.id IN movie_ids
"""
rows = await shared_connection().fetch_all(bindparams(query, values))
aggreg: dict[ULID, tuple[Movie, list[Rating]]] = {}
for row in rows:
movie = demux(Movie, row)
_, ratings = aggreg.setdefault(movie.id, (movie, []))
try:
rating = demux(Rating, row)
except:
pass
else:
ratings.append(rating)
return aggreg.values()
def bindparams(query: str, values: dict):
"""Bind values to a query.

View file

@ -23,7 +23,7 @@ from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from . import config, db, imdb, imdb_import
from .db import close_connection_pool, find_ratings, open_connection_pool
from .db import close_connection_pool, find_movies, find_ratings, open_connection_pool
from .middleware.responsetime import ResponseTimeMiddleware
from .models import Group, Movie, User, asplain
from .types import ULID
@ -251,13 +251,64 @@ def not_implemented():
@route("/movies")
@requires(["private"])
async def get_movies(request):
imdb_id = request.query_params.get("imdb_id")
@requires(["authenticated"])
async def list_movies(request):
movie = await db.get(Movie, imdb_id=imdb_id)
params = request.query_params
user = await auth_user(request)
user_ids = set()
if group_id := params.get("group_id"):
group_id = as_ulid(group_id)
group = await db.get(Group, id=str(group_id))
if not group:
return not_found("Group not found.")
is_allowed = is_admin(request) or user and user.has_access(group_id)
if not is_allowed:
return forbidden("No access to group.")
user_ids |= {ULID(u["id"]) for u in group.users}
if user_id := params.get("user_id"):
user_id = as_ulid(user_id)
# Currently a user may only directly access their own ratings.
is_allowed = is_admin(request) or user and user.id == user_id
if not is_allowed:
return forbidden("No access to user.")
user_ids |= {user_id}
if imdb_id := params.get("imdb_id"):
# XXX missing support for user_ids and user_scores
movies = [await db.get(Movie, imdb_id=imdb_id)]
resp = [asplain(m) for m in movies]
else:
per_page = as_int(params.get("per_page"), max=1000, default=5)
page = as_int(params.get("page"), min=1, default=1)
movieratings = await find_movies(
title=params.get("title"),
media_type=params.get("media_type"),
exact=truthy(params.get("exact")),
ignore_tv_episodes=truthy(params.get("ignore_tv_episodes")),
include_unrated=truthy(params.get("include_unrated")),
yearcomp=yearcomp(params["year"]) if "year" in params else None,
limit_rows=per_page,
skip_rows=(page - 1) * per_page,
user_ids=list(user_ids),
)
resp = []
for movie, ratings in movieratings:
mov = asplain(movie)
mov["user_scores"] = [rating.score for rating in ratings]
resp.append(mov)
resp = [asplain(movie)] if movie else []
return JSONResponse(resp)