fix up movies index route [wip]
This commit is contained in:
parent
7cc540b6fd
commit
bae0415a24
4 changed files with 210 additions and 8 deletions
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
}
|
||||||
|
|
|
||||||
120
unwind/db.py
120
unwind/db.py
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue