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> <span class="mediatype">{{ item.media_type }}</span>
</td> </td>
<td> <td>
<span class="score imdb-score tag is-large" title="IMDb rating (1-10)">{{ imdb_rating(item.imdb_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)">{{ imdb_rating(item.user_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>
<td> <td>
<span>{{ duration(item.runtime) }}</span> <span>{{ duration(item.runtime) }}</span>
@ -44,6 +44,20 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue" 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) { function imdb_rating(score) {
if (score == null) { if (score == null) {
@ -85,7 +99,9 @@ export default defineComponent({
observer.observe(this.$refs.sentinel) observer.observe(this.$refs.sentinel)
}, },
methods: { methods: {
avg_imdb_rating,
imdb_rating, imdb_rating,
imdb_stdev,
duration, duration,
}, },
}) })

View file

@ -1,3 +1,18 @@
export function is_object(x) { export function is_object(x) {
return x !== null && typeof x === "object" && !Array.isArray(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) 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): def bindparams(query: str, values: dict):
"""Bind values to a query. """Bind values to a query.

View file

@ -23,7 +23,7 @@ from starlette.responses import JSONResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from . import config, db, imdb, imdb_import 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 .middleware.responsetime import ResponseTimeMiddleware
from .models import Group, Movie, User, asplain from .models import Group, Movie, User, asplain
from .types import ULID from .types import ULID
@ -251,13 +251,64 @@ def not_implemented():
@route("/movies") @route("/movies")
@requires(["private"]) @requires(["authenticated"])
async def get_movies(request): async def list_movies(request):
imdb_id = request.query_params.get("imdb_id")
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) return JSONResponse(resp)