Merge branch 'ui'
This commit is contained in:
commit
792806f304
31 changed files with 2934 additions and 111 deletions
|
|
@ -1,2 +1,4 @@
|
|||
*.local
|
||||
/data
|
||||
/tests
|
||||
/unwind-ui
|
||||
|
|
|
|||
2
Procfile
Normal file
2
Procfile
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
server: ./run dev-server
|
||||
ui: ./run dev-ui
|
||||
67
poetry.lock
generated
67
poetry.lock
generated
|
|
@ -11,7 +11,7 @@ typing_extensions = ">=3.7.2"
|
|||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
version = "3.3.4"
|
||||
version = "3.4.1"
|
||||
description = "ASGI specs, helper code, and adapters"
|
||||
category = "main"
|
||||
optional = false
|
||||
|
|
@ -59,19 +59,22 @@ lxml = ["lxml"]
|
|||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2020.12.5"
|
||||
version = "2021.5.30"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "chardet"
|
||||
version = "4.0.0"
|
||||
description = "Universal encoding detector for Python 2 and 3"
|
||||
name = "charset-normalizer"
|
||||
version = "2.0.4"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
python-versions = ">=3.5.0"
|
||||
|
||||
[package.extras]
|
||||
unicode_backport = ["unicodedata2"]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
|
|
@ -138,11 +141,11 @@ lxml = ["lxml"]
|
|||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "2.10"
|
||||
version = "3.2"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
|
|
@ -154,11 +157,11 @@ python-versions = "*"
|
|||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "20.9"
|
||||
version = "21.0"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2"
|
||||
|
|
@ -213,21 +216,21 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.25.1"
|
||||
version = "2.26.0"
|
||||
description = "Python HTTP for Humans."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2017.4.17"
|
||||
chardet = ">=3.0.2,<5"
|
||||
idna = ">=2.5,<3"
|
||||
charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
|
||||
idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
|
||||
urllib3 = ">=1.21.1,<1.27"
|
||||
|
||||
[package.extras]
|
||||
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
|
||||
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
|
|
@ -302,16 +305,16 @@ python-versions = "*"
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.4"
|
||||
version = "1.26.6"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
brotli = ["brotlipy (>=0.6.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
|
|
@ -348,8 +351,8 @@ aiosqlite = [
|
|||
{file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"},
|
||||
]
|
||||
asgiref = [
|
||||
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
|
||||
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
|
||||
{file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
|
||||
{file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
|
||||
]
|
||||
atomicwrites = [
|
||||
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
|
||||
|
|
@ -365,12 +368,12 @@ beautifulsoup4 = [
|
|||
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
|
||||
]
|
||||
certifi = [
|
||||
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
|
||||
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
|
||||
{file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
|
||||
{file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
|
||||
]
|
||||
chardet = [
|
||||
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
|
||||
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
|
||||
charset-normalizer = [
|
||||
{file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
|
||||
{file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
|
||||
|
|
@ -393,16 +396,16 @@ html5lib = [
|
|||
{file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
|
||||
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
|
||||
{file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
|
||||
{file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
|
||||
]
|
||||
iniconfig = [
|
||||
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
|
||||
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
|
||||
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
|
||||
{file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
|
||||
{file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
|
||||
]
|
||||
pluggy = [
|
||||
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
|
||||
|
|
@ -421,8 +424,8 @@ pytest = [
|
|||
{file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
|
||||
]
|
||||
requests = [
|
||||
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
|
||||
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
|
||||
{file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
|
||||
{file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
|
||||
]
|
||||
six = [
|
||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||
|
|
@ -486,8 +489,8 @@ ulid-py = [
|
|||
{file = "ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987"},
|
||||
]
|
||||
urllib3 = [
|
||||
{file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
|
||||
{file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
|
||||
{file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
|
||||
{file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
|
||||
]
|
||||
uvicorn = [
|
||||
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},
|
||||
|
|
|
|||
|
|
@ -4,4 +4,7 @@ cd "$RUN_DIR"
|
|||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
exec poetry export -o requirements.txt
|
||||
poetry export -o requirements.txt
|
||||
|
||||
cd unwind-ui
|
||||
npm run build
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ cd "$RUN_DIR"
|
|||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
exec uvicorn unwind:create_app --factory --reload
|
||||
exec honcho start
|
||||
|
|
|
|||
7
scripts/dev-server
Executable file
7
scripts/dev-server
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"
|
||||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
exec uvicorn unwind:create_app --factory --reload
|
||||
7
scripts/dev-ui
Executable file
7
scripts/dev-ui
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"/unwind-ui
|
||||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
exec npm run dev
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"
|
||||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
isort --profile black unwind
|
||||
black unwind
|
||||
"$RUN_BIN" lint-js "$@"
|
||||
"$RUN_BIN" lint-py "$@"
|
||||
|
|
|
|||
21
scripts/lint-js
Executable file
21
scripts/lint-js
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"
|
||||
|
||||
if [ "${1:-}" = '--fix' ]; then
|
||||
mode=fix
|
||||
else
|
||||
mode=check
|
||||
fi
|
||||
|
||||
if [ "$mode" = 'fix' ]; then
|
||||
prettier_opts=--write
|
||||
else
|
||||
prettier_opts=--check
|
||||
fi
|
||||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
cd unwind-ui
|
||||
npm run lint ||:
|
||||
npx prettier $prettier_opts 'vite.config.ts' 'src/**/*.{js,ts,vue}'
|
||||
8
scripts/lint-py
Executable file
8
scripts/lint-py
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh -eu
|
||||
|
||||
cd "$RUN_DIR"
|
||||
|
||||
[ -z "${DEBUG:-}" ] || set -x
|
||||
|
||||
isort --profile black unwind
|
||||
black unwind
|
||||
2
unwind-ui/.gitignore
vendored
Normal file
2
unwind-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
dist
|
||||
12
unwind-ui/index.html
Normal file
12
unwind-ui/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>🧶 Unwind</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1698
unwind-ui/package-lock.json
generated
Normal file
1698
unwind-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
unwind-ui/package.json
Normal file
26
unwind-ui/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "vue-tsc --noEmit",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bulma": "^0.9.3",
|
||||
"vue": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^1.2.5",
|
||||
"@vue/compiler-sfc": "^3.0.5",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.4.2",
|
||||
"vue-tsc": "^0.0.24"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "always",
|
||||
"printWidth": 88,
|
||||
"trailingComma": "all",
|
||||
"semi": false
|
||||
}
|
||||
}
|
||||
259
unwind-ui/src/App.vue
Normal file
259
unwind-ui/src/App.vue
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<template>
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<user-login
|
||||
class="is-justify-content-end"
|
||||
@login="(login) => (credentials = login)"
|
||||
/>
|
||||
|
||||
<div class="field is-flex is-justify-content-end" v-if="login_user">
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-small">
|
||||
<select v-model="filter_group" @change="search">
|
||||
<option selected value="">Me ({{ login_user.name }})</option>
|
||||
<option v-for="g in login_user.groups" :key="g.id" :value="g.id">
|
||||
{{ g.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="icon is-small is-left">
|
||||
<span class="fas fa-group">👥</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field has-addons mt-6">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
class="input is-loading"
|
||||
type="text"
|
||||
placeholder="Movie title"
|
||||
v-model="query"
|
||||
@change="search"
|
||||
/>
|
||||
</div>
|
||||
<p class="control">
|
||||
<span class="select">
|
||||
<select v-model="media_type" @change="search">
|
||||
<option value="">(any type)</option>
|
||||
<option v-for="t in media_types" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<div class="control">
|
||||
<a class="button is-info" :disabled="!active"> Search </a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<movie-list :items="items" @reachBottom="loadMore" />
|
||||
|
||||
<footer class="footer" v-if="is_end">
|
||||
<p class="subtitle content has-text-centered is-italic">~ fin ~</p>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import config from "./config.ts"
|
||||
import { is_object } from "./utils.ts"
|
||||
|
||||
const media_types = [
|
||||
"Movie",
|
||||
"Radio Episode",
|
||||
"Radio Series",
|
||||
"Short",
|
||||
"TV Episode",
|
||||
"TV Mini Series",
|
||||
"TV Movie",
|
||||
"TV Series",
|
||||
"TV Short",
|
||||
"TV Special",
|
||||
"Video",
|
||||
"Video Game",
|
||||
]
|
||||
|
||||
function clean_obj(o) {
|
||||
return Object.fromEntries(Object.entries(o).filter(([k, v]) => v))
|
||||
}
|
||||
|
||||
function api_url(resource, params = []) {
|
||||
const url = new URL(resource, config.api_url)
|
||||
|
||||
if (is_object(params)) {
|
||||
params = clean_obj(params)
|
||||
}
|
||||
|
||||
if (is_object(params) || params.length) {
|
||||
url.search = new URLSearchParams(params)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function set_view_params(params) {
|
||||
history.replaceState(null, "", "#" + new URLSearchParams(clean_obj(params)))
|
||||
}
|
||||
|
||||
function view_params() {
|
||||
const fragment = (window.location.hash || "").replace(/^#/, "")
|
||||
const params = new URLSearchParams(fragment)
|
||||
return {
|
||||
query: params.get("query") || "",
|
||||
type: params.get("type") || "",
|
||||
}
|
||||
}
|
||||
|
||||
async function req(url, opts = {}, { user_id = "", secret = "" }, data = null) {
|
||||
opts.headers = opts.headers || {}
|
||||
|
||||
if (data !== null) {
|
||||
opts.body = JSON.stringify(data)
|
||||
opts.headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
if (secret) {
|
||||
const credentials = btoa(`${user_id}:${secret}`)
|
||||
opts.headers["Authorization"] = `Basic ${credentials}`
|
||||
opts.mode = "cors"
|
||||
opts.credentials = "include"
|
||||
}
|
||||
|
||||
const resp = await window.fetch(url, opts)
|
||||
if (!resp.ok) {
|
||||
throw resp
|
||||
}
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async function get(resource, params = [], user = {}) {
|
||||
const url = api_url(resource, params)
|
||||
const opts = {}
|
||||
return await req(url, opts, user)
|
||||
}
|
||||
|
||||
async function patch(resource, params = [], user = {}, data = null) {
|
||||
const url = api_url(resource, params)
|
||||
const opts = { method: "PATCH" }
|
||||
return await req(url, opts, user, data)
|
||||
}
|
||||
|
||||
function debounce_async(ms, func) {
|
||||
let abort
|
||||
|
||||
return async (...args) => {
|
||||
if (abort) {
|
||||
abort()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(func(...args))
|
||||
}, ms)
|
||||
|
||||
abort = () => {
|
||||
reject("Function call aborted, replaced by a later call.")
|
||||
clearTimeout(timer)
|
||||
abort = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const debounced_get = debounce_async(100, get)
|
||||
|
||||
type ULID = string
|
||||
type User = {
|
||||
id: ULID
|
||||
name: string
|
||||
groups: Array<{ id: ULID }>
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
data: () => ({
|
||||
query: "",
|
||||
items: [],
|
||||
credentials: { user_id: "", secret: "" },
|
||||
login_user: null,
|
||||
page: 1,
|
||||
is_end: false,
|
||||
media_types,
|
||||
media_type: "",
|
||||
active: false,
|
||||
filter_group: "",
|
||||
}),
|
||||
mounted() {
|
||||
const { query, type } = view_params()
|
||||
this.query = query
|
||||
this.media_type = media_types.includes(type) ? type : ""
|
||||
},
|
||||
methods: {
|
||||
async search() {
|
||||
this.active = true
|
||||
set_view_params({ query: this.query, type: this.media_type })
|
||||
|
||||
if (!this.query) {
|
||||
return
|
||||
}
|
||||
|
||||
this.page = 1
|
||||
this.is_end = false
|
||||
this.items = []
|
||||
await this.loadMore()
|
||||
},
|
||||
async loadMore() {
|
||||
this.active = false
|
||||
|
||||
if (!this.query || this.is_end) {
|
||||
return
|
||||
}
|
||||
|
||||
const per_page = 100
|
||||
const media_type = this.media_type === "" ? null : this.media_type
|
||||
const user_id = (this.filter_group === "" && this.credentials.user_id) || null
|
||||
const group_id = user_id === null && this.filter_group ? this.filter_group : null
|
||||
const more = await debounced_get(
|
||||
"movies",
|
||||
{
|
||||
title: this.query,
|
||||
per_page,
|
||||
include_unrated: true,
|
||||
page: this.page,
|
||||
media_type,
|
||||
user_id,
|
||||
group_id,
|
||||
},
|
||||
this.credentials,
|
||||
)
|
||||
this.items = this.items.concat(more)
|
||||
|
||||
if (more.length < per_page) {
|
||||
this.is_end = true
|
||||
} else {
|
||||
this.page += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async credentials(creds) {
|
||||
if (!creds.user_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const user: User = await get(`users/${creds.user_id}`, {}, creds)
|
||||
this.login_user = user
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
margin-top: 60px;
|
||||
}
|
||||
</style>
|
||||
138
unwind-ui/src/components/MovieList.vue
Normal file
138
unwind-ui/src/components/MovieList.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>title</th>
|
||||
<th>year</th>
|
||||
<th>type</th>
|
||||
<th>rating</th>
|
||||
<th>runtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id" :data-unwind-id="item.id">
|
||||
<td v-if="item.original_title">
|
||||
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
|
||||
<span>{{ item.original_title }}</span>
|
||||
</a>
|
||||
<span v-if="item.title != item.original_title"> ({{ item.title }})</span>
|
||||
</td>
|
||||
<td v-else>
|
||||
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
|
||||
<span>{{ item.title }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="year">{{ item.release_year }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mediatype">{{ item.media_type }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div ref="sentinel"></div>
|
||||
</template>
|
||||
|
||||
<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) / 10 + 10) / 10
|
||||
}
|
||||
|
||||
function imdb_rating(score) {
|
||||
if (score == null) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const deci = 10 * imdb_rating_from_score(score)
|
||||
return `${(deci / 10) | 0}.${deci % 10}`
|
||||
}
|
||||
|
||||
function duration(minutes_total) {
|
||||
if (minutes_total == null) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const m = minutes_total % 60
|
||||
const h = (minutes_total / 60) | 0
|
||||
return `${h} h ${m} m`
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["reach-bottom"],
|
||||
mounted() {
|
||||
const options = {
|
||||
rootMargin: "500px",
|
||||
}
|
||||
const observer = new IntersectionObserver(([e]) => {
|
||||
if (!e.isIntersecting) {
|
||||
return
|
||||
}
|
||||
this.$emit("reach-bottom")
|
||||
}, options)
|
||||
observer.observe(this.$refs.sentinel)
|
||||
},
|
||||
methods: {
|
||||
avg_imdb_rating,
|
||||
imdb_rating,
|
||||
imdb_stdev,
|
||||
duration,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.year {
|
||||
color: grey;
|
||||
}
|
||||
.mediatype {
|
||||
color: grey;
|
||||
}
|
||||
.score {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
.score + .score {
|
||||
margin-left: 1em;
|
||||
}
|
||||
.imdb-score {
|
||||
background-color: rgb(245, 197, 24);
|
||||
}
|
||||
.user-score {
|
||||
}
|
||||
</style>
|
||||
70
unwind-ui/src/components/UserLogin.vue
Normal file
70
unwind-ui/src/components/UserLogin.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input is-small"
|
||||
type="text"
|
||||
placeholder="User ID"
|
||||
v-model="user_id"
|
||||
@change="active = true"
|
||||
/>
|
||||
<span class="icon is-small is-left">
|
||||
<span class="fas fa-user">👤</span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control has-icons-left">
|
||||
<input
|
||||
class="input is-small"
|
||||
type="password"
|
||||
placeholder="Secret"
|
||||
v-model="secret"
|
||||
@change="active = true"
|
||||
/>
|
||||
<span class="icon is-small is-left">
|
||||
<span class="fas fa-lock">🔓</span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-primary is-small" :disabled="!active" @click="login">
|
||||
Login
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
emits: ["login"],
|
||||
|
||||
data: () => ({
|
||||
user_id: window.localStorage.user_id || "",
|
||||
secret: window.localStorage.secret || "",
|
||||
active: true,
|
||||
}),
|
||||
mounted() {
|
||||
const { user_id, secret } = this
|
||||
if (user_id && secret) {
|
||||
this.login()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login() {
|
||||
const { user_id, secret } = this
|
||||
this.$emit("login", { user_id, secret })
|
||||
this.active = false
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
user_id(newval) {
|
||||
window.localStorage.user_id = newval
|
||||
},
|
||||
secret(newval) {
|
||||
window.localStorage.secret = newval
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
3
unwind-ui/src/config.ts
Normal file
3
unwind-ui/src/config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
api_url: `${process.env.API_URL}v1/`,
|
||||
}
|
||||
12
unwind-ui/src/main.ts
Normal file
12
unwind-ui/src/main.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
|
||||
import MovieList from "./components/MovieList.vue"
|
||||
import UserLogin from "./components/UserLogin.vue"
|
||||
|
||||
import "bulma/css/bulma.css"
|
||||
|
||||
const app = createApp(App)
|
||||
app.component("movie-list", MovieList)
|
||||
app.component("user-login", UserLogin)
|
||||
app.mount("#app")
|
||||
6
unwind-ui/src/shims-vue.d.ts
vendored
Normal file
6
unwind-ui/src/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare module "*.vue" {
|
||||
import { DefineComponent } from "vue"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
18
unwind-ui/src/utils.ts
Normal file
18
unwind-ui/src/utils.ts
Normal file
|
|
@ -0,0 +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)))
|
||||
}
|
||||
1
unwind-ui/src/vite-env.d.ts
vendored
Normal file
1
unwind-ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
14
unwind-ui/tsconfig.json
Normal file
14
unwind-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
13
unwind-ui/vite.config.ts
Normal file
13
unwind-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from "vite"
|
||||
import vue from "@vitejs/plugin-vue"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: process.env.BASE_URL || "/",
|
||||
define: {
|
||||
"process.env.API_URL": JSON.stringify(
|
||||
process.env.API_URL || "http://localhost:8000/api/",
|
||||
),
|
||||
},
|
||||
plugins: [vue()],
|
||||
})
|
||||
|
|
@ -16,4 +16,6 @@ config_path = os.getenv("UNWIND_CONFIG", datadir / "config.toml")
|
|||
|
||||
_config = toml.load(config_path)
|
||||
|
||||
api_credentials = _config["api"]["credentials"]
|
||||
api_base = _config["api"].get("base", "/api/")
|
||||
api_cors = _config["api"].get("cors", "*")
|
||||
api_credentials = _config["api"].get("credentials", {})
|
||||
|
|
|
|||
138
unwind/db.py
138
unwind/db.py
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
|
@ -19,8 +18,10 @@ from .models import (
|
|||
optional_fields,
|
||||
utcnow,
|
||||
)
|
||||
from .types import ULID
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
_shared_connection: Optional[Database] = None
|
||||
|
||||
|
|
@ -256,6 +257,12 @@ async def update(item):
|
|||
await shared_connection().execute(query=query, values=values)
|
||||
|
||||
|
||||
async def remove(item):
|
||||
values = asplain(item, fields_={"id"})
|
||||
query = f"DELETE FROM {item._table} WHERE id=:id"
|
||||
await shared_connection().execute(query=query, values=values)
|
||||
|
||||
|
||||
async def add_or_update_user(user: User):
|
||||
db_user = await get(User, imdb_id=user.imdb_id)
|
||||
if not db_user:
|
||||
|
|
@ -453,6 +460,135 @@ 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()})
|
||||
|
||||
|
||||
def sql_in(column: str, values: list[T]) -> tuple[str, dict[str, T]]:
|
||||
c = column.replace(".", "___")
|
||||
value_map = {f"{c}_{i}": v for i, v in enumerate(values, start=1)}
|
||||
placeholders = ",".join(":" + k for k in value_map)
|
||||
return f"{column} IN ({placeholders})", value_map
|
||||
|
||||
|
||||
async def ratings_for_movies(
|
||||
movie_ids: Iterable[ULID], user_ids: Iterable[ULID] = []
|
||||
) -> Iterable[Rating]:
|
||||
values: dict[str, str] = {}
|
||||
conditions: list[str] = []
|
||||
|
||||
q, vm = sql_in("movie_id", [str(m) for m in movie_ids])
|
||||
conditions.append(q)
|
||||
values.update(vm)
|
||||
|
||||
if user_ids:
|
||||
q, vm = sql_in("user_id", [str(m) for m in user_ids])
|
||||
conditions.append(q)
|
||||
values.update(vm)
|
||||
|
||||
query = f"""
|
||||
SELECT {','.join(sql_fields(Rating))}
|
||||
FROM {Rating._table}
|
||||
WHERE {' AND '.join(f'({c})' for c in conditions) if conditions else '1=1'}
|
||||
"""
|
||||
|
||||
rows = await shared_connection().fetch_all(query, values)
|
||||
|
||||
return (fromplain(Rating, row) for row in rows)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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))
|
||||
|
||||
movies = [fromplain(Movie, row) for row in rows]
|
||||
|
||||
if not user_ids:
|
||||
return ((m, []) for m in movies)
|
||||
|
||||
ratings = await ratings_for_movies((m.id for m in movies), user_ids)
|
||||
|
||||
aggreg: dict[ULID, tuple[Movie, list[Rating]]] = {m.id: (m, []) for m in movies}
|
||||
for rating in ratings:
|
||||
aggreg[rating.movie_id][1].append(rating)
|
||||
|
||||
return aggreg.values()
|
||||
|
||||
|
||||
def bindparams(query: str, values: dict):
|
||||
"""Bind values to a query.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from dataclasses import dataclass, fields
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast
|
||||
from typing import Generator, Literal, Optional, Type, TypeVar, overload
|
||||
|
||||
from . import config, db, request
|
||||
from .db import add_or_update_many_movies
|
||||
|
|
@ -13,6 +13,7 @@ from .models import Movie
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# See
|
||||
# - https://www.imdb.com/interfaces/
|
||||
|
|
@ -120,6 +121,20 @@ def count_lines(path) -> int:
|
|||
return i
|
||||
|
||||
|
||||
@overload
|
||||
def read_imdb_tsv(
|
||||
path, row_type, *, unpack: Literal[False]
|
||||
) -> Generator[list[str], None, None]:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
def read_imdb_tsv(
|
||||
path, row_type: Type[T], *, unpack: Literal[True] = True
|
||||
) -> Generator[T, None, None]:
|
||||
...
|
||||
|
||||
|
||||
def read_imdb_tsv(path, row_type, *, unpack=True):
|
||||
with gzip.open(path, "rt", newline="") as f:
|
||||
rows = csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE)
|
||||
|
|
@ -158,7 +173,6 @@ def read_ratings(path):
|
|||
def read_ratings_as_mapping(path):
|
||||
"""Optimized function to quickly load all ratings."""
|
||||
rows = read_imdb_tsv(path, RatingRow, unpack=False)
|
||||
rows = cast(list[list[str]], rows)
|
||||
return {r[0]: (round(100 * (float(r[1]) - 1) / 9), int(r[2])) for r in rows}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import (
|
|||
Annotated,
|
||||
Any,
|
||||
ClassVar,
|
||||
Literal,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
|
|
@ -16,6 +17,8 @@ from typing import (
|
|||
|
||||
from .types import ULID
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def annotations(tp: Type) -> Optional[tuple]:
|
||||
return tp.__metadata__ if hasattr(tp, "__metadata__") else None
|
||||
|
|
@ -69,12 +72,15 @@ def optional_fields(o):
|
|||
yield f
|
||||
|
||||
|
||||
def asplain(o) -> dict[str, Any]:
|
||||
def asplain(o, *, fields_: set = None) -> dict[str, Any]:
|
||||
validate(o)
|
||||
|
||||
d = {}
|
||||
for f in fields(o):
|
||||
|
||||
if fields_ is not None and f.name not in fields_:
|
||||
continue
|
||||
|
||||
target = f.type
|
||||
# XXX this doesn't properly support any kind of nested types
|
||||
if (otype := optional_type(f.type)) is not None:
|
||||
|
|
@ -99,7 +105,7 @@ def asplain(o) -> dict[str, Any]:
|
|||
return d
|
||||
|
||||
|
||||
def fromplain(cls, d: dict[str, Any]):
|
||||
def fromplain(cls: Type[T], d: dict[str, Any]) -> T:
|
||||
dd = {}
|
||||
for f in fields(cls):
|
||||
|
||||
|
|
@ -229,7 +235,6 @@ class Movie:
|
|||
self._is_lazy = False
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
_RelationSentinel = object()
|
||||
"""Mark a model field as containing external data.
|
||||
|
||||
|
|
@ -271,6 +276,13 @@ class Rating:
|
|||
)
|
||||
|
||||
|
||||
Access = Literal[
|
||||
"r", # read
|
||||
"i", # index
|
||||
"w", # write
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
_table: ClassVar[str] = "users"
|
||||
|
|
@ -278,6 +290,21 @@ class User:
|
|||
id: ULID = field(default_factory=ULID)
|
||||
imdb_id: str = None
|
||||
name: str = None # canonical user name
|
||||
secret: str = None
|
||||
groups: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def has_access(self, group_id: Union[ULID, str], access: Access = "r"):
|
||||
group_id = group_id if isinstance(group_id, str) else str(group_id)
|
||||
return any(g["id"] == group_id and access == g["access"] for g in self.groups)
|
||||
|
||||
def set_access(self, group_id: Union[ULID, str], access: Access):
|
||||
group_id = group_id if isinstance(group_id, str) else str(group_id)
|
||||
for g in self.groups:
|
||||
if g["id"] == group_id:
|
||||
g["access"] = access
|
||||
break
|
||||
else:
|
||||
self.groups.append({"id": group_id, "access": access})
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -286,5 +313,4 @@ class Group:
|
|||
|
||||
id: ULID = field(default_factory=ULID)
|
||||
name: str = None
|
||||
secret: str = None
|
||||
users: list[dict[str, str]] = field(default_factory=list)
|
||||
|
|
|
|||
22
unwind/sql/20210801-201151--add-user-secret.sql
Normal file
22
unwind/sql/20210801-201151--add-user-secret.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- add secret to users
|
||||
|
||||
CREATE TABLE _migrate_users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
imdb_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
secret TEXT NOT NULL
|
||||
);;
|
||||
|
||||
INSERT INTO _migrate_users
|
||||
SELECT
|
||||
id,
|
||||
imdb_id,
|
||||
name,
|
||||
'' AS secret
|
||||
FROM users
|
||||
WHERE true;;
|
||||
|
||||
DROP TABLE users;;
|
||||
|
||||
ALTER TABLE _migrate_users
|
||||
RENAME TO users;;
|
||||
45
unwind/sql/20210802-212312--add-group-admins.sql
Normal file
45
unwind/sql/20210802-212312--add-group-admins.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
-- add group admins
|
||||
|
||||
--- remove secrets from groups
|
||||
CREATE TABLE _migrate_groups (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
users TEXT NOT NULL -- JSON array
|
||||
);;
|
||||
|
||||
INSERT INTO _migrate_groups
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
users
|
||||
FROM groups
|
||||
WHERE true;;
|
||||
|
||||
DROP TABLE groups;;
|
||||
|
||||
ALTER TABLE _migrate_groups
|
||||
RENAME TO groups;;
|
||||
|
||||
--- add group access to users
|
||||
CREATE TABLE _migrate_users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
imdb_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
groups TEXT NOT NULL -- JSON array
|
||||
);;
|
||||
|
||||
INSERT INTO _migrate_users
|
||||
SELECT
|
||||
id,
|
||||
imdb_id,
|
||||
name,
|
||||
secret,
|
||||
'[]' AS groups
|
||||
FROM users
|
||||
WHERE true;;
|
||||
|
||||
DROP TABLE users;;
|
||||
|
||||
ALTER TABLE _migrate_users
|
||||
RENAME TO users;;
|
||||
383
unwind/web.py
383
unwind/web.py
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
import logging
|
||||
import secrets
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.authentication import (
|
||||
|
|
@ -17,22 +17,29 @@ from starlette.background import BackgroundTask
|
|||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
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
|
||||
from .utils import b64encode, phc_compare, phc_scrypt
|
||||
from .utils import b64decode, b64encode, phc_compare, phc_scrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# XXX we probably don't need a group secret anymore, if group access is managed
|
||||
# on a user level; a group secret would be a separate user with full group
|
||||
# access
|
||||
|
||||
class BearingUser(BaseUser):
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
|
||||
class AuthedUser(BaseUser):
|
||||
def __init__(self, user_id: str, secret: str):
|
||||
self.user_id = user_id
|
||||
self.secret = secret
|
||||
|
||||
|
||||
class BearerAuthBackend(AuthenticationBackend):
|
||||
|
|
@ -43,25 +50,33 @@ class BearerAuthBackend(AuthenticationBackend):
|
|||
if "Authorization" not in request.headers:
|
||||
return
|
||||
|
||||
# XXX should we remove the auth header after reading, for security reasons?
|
||||
|
||||
auth = request.headers["Authorization"]
|
||||
try:
|
||||
scheme, token = auth.split()
|
||||
scheme, credentials = auth.split()
|
||||
except ValueError:
|
||||
raise AuthenticationError("Invalid auth credentials")
|
||||
|
||||
if scheme.lower() != "bearer":
|
||||
return
|
||||
|
||||
roles = []
|
||||
|
||||
is_admin = token in self.admin_tokens
|
||||
|
||||
if is_admin:
|
||||
user = SimpleUser(self.admin_tokens[token])
|
||||
if scheme.lower() == "bearer":
|
||||
is_admin = credentials in self.admin_tokens
|
||||
if not is_admin:
|
||||
return
|
||||
name = self.admin_tokens[credentials]
|
||||
user = SimpleUser(name)
|
||||
roles.append("admin")
|
||||
|
||||
elif scheme.lower() == "basic":
|
||||
try:
|
||||
name, secret = b64decode(credentials).decode().split(":")
|
||||
except:
|
||||
raise AuthenticationError("Invalid auth credentials")
|
||||
user = AuthedUser(name, secret)
|
||||
|
||||
else:
|
||||
user = BearingUser(token)
|
||||
return
|
||||
|
||||
return AuthCredentials(["authenticated", *roles]), user
|
||||
|
||||
|
|
@ -101,11 +116,63 @@ def as_int(x, *, max: int = None, min: Optional[int] = 1, default: int = None):
|
|||
|
||||
def as_ulid(s: str) -> ULID:
|
||||
try:
|
||||
if not s:
|
||||
raise ValueError("Invalid ULID.")
|
||||
|
||||
return ULID(s)
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(422, "Not a valid ULID.")
|
||||
|
||||
|
||||
@overload
|
||||
async def json_from_body(request) -> dict:
|
||||
...
|
||||
|
||||
|
||||
@overload
|
||||
async def json_from_body(request, keys: list[str]) -> list:
|
||||
...
|
||||
|
||||
|
||||
async def json_from_body(request, keys: list[str] = None):
|
||||
if not await request.body():
|
||||
data = {}
|
||||
|
||||
else:
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
raise HTTPException(422, "Invalid JSON content.")
|
||||
|
||||
if not keys:
|
||||
return data
|
||||
|
||||
try:
|
||||
return [data[k] for k in keys]
|
||||
except KeyError as err:
|
||||
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
|
||||
|
||||
|
||||
def is_admin(request):
|
||||
return "admin" in request.auth.scopes
|
||||
|
||||
|
||||
async def auth_user(request) -> Optional[User]:
|
||||
if not isinstance(request.user, AuthedUser):
|
||||
return
|
||||
|
||||
user = await db.get(User, id=request.user.user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
is_authed = phc_compare(secret=request.user.secret, phc_string=user.secret)
|
||||
if not is_authed:
|
||||
return
|
||||
|
||||
return user
|
||||
|
||||
|
||||
_routes = []
|
||||
|
||||
|
||||
|
|
@ -123,6 +190,7 @@ route.registered = _routes
|
|||
|
||||
@route("/groups/{group_id}/ratings")
|
||||
async def get_ratings_for_group(request):
|
||||
|
||||
group_id = as_ulid(request.path_params["group_id"])
|
||||
group = await db.get(Group, id=str(group_id))
|
||||
|
||||
|
|
@ -178,24 +246,77 @@ def not_found(reason: str = "Not Found"):
|
|||
return JSONResponse({"error": reason}, status_code=404)
|
||||
|
||||
|
||||
def not_implemented():
|
||||
raise HTTPException(404, "Not yet 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)
|
||||
|
||||
|
||||
@route("/movies", methods=["POST"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def add_movie(request):
|
||||
pass
|
||||
|
||||
|
||||
import_lock = asyncio.Lock()
|
||||
not_implemented()
|
||||
|
||||
|
||||
@route("/movies/_reload_imdb", methods=["GET"])
|
||||
|
|
@ -212,6 +333,8 @@ async def progress_for_load_imdb_movies(request):
|
|||
status = None
|
||||
if error:
|
||||
status = "Error during import."
|
||||
elif percent == 0.0 and progress.stopped:
|
||||
status = "Import skipped."
|
||||
elif percent < 100:
|
||||
status = "Import is running."
|
||||
else:
|
||||
|
|
@ -228,10 +351,14 @@ async def progress_for_load_imdb_movies(request):
|
|||
return JSONResponse(resp)
|
||||
|
||||
|
||||
_import_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@route("/movies/_reload_imdb", methods=["POST"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def load_imdb_movies(request):
|
||||
async with import_lock:
|
||||
|
||||
async with _import_lock:
|
||||
progress = await db.get_import_progress()
|
||||
if progress and not progress.stopped:
|
||||
return JSONResponse(
|
||||
|
|
@ -250,26 +377,166 @@ async def load_imdb_movies(request):
|
|||
@route("/users")
|
||||
@requires(["authenticated", "admin"])
|
||||
async def list_users(request):
|
||||
|
||||
users = await db.get_all(User)
|
||||
|
||||
return JSONResponse([asplain(u) for u in users])
|
||||
|
||||
|
||||
@route("/users", methods=["POST"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def add_user(request):
|
||||
pass
|
||||
|
||||
name, imdb_id = await json_from_body(request, ["name", "imdb_id"])
|
||||
|
||||
# XXX restrict name
|
||||
# XXX check if imdb_id is well-formed
|
||||
|
||||
secret = secrets.token_bytes()
|
||||
|
||||
user = User(name=name, imdb_id=imdb_id, secret=phc_scrypt(secret))
|
||||
await db.add(user)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"secret": b64encode(secret),
|
||||
"user": asplain(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@route("/users/{user_id}")
|
||||
@requires(["authenticated"])
|
||||
async def show_user(request):
|
||||
|
||||
user_id = as_ulid(request.path_params["user_id"])
|
||||
|
||||
if is_admin(request):
|
||||
user = await db.get(User, id=str(user_id))
|
||||
|
||||
else:
|
||||
user = await auth_user(request)
|
||||
|
||||
if not user:
|
||||
return not_found()
|
||||
|
||||
is_allowed = user.id == user_id
|
||||
if not is_allowed:
|
||||
return forbidden()
|
||||
|
||||
# Redact `secret`
|
||||
resp = asplain(user)
|
||||
resp["secret"] = None
|
||||
|
||||
# Fix `groups`
|
||||
resp["groups"] = user.groups
|
||||
|
||||
return JSONResponse(resp)
|
||||
|
||||
|
||||
@route("/users/{user_id}", methods=["DELETE"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def remove_user(request):
|
||||
|
||||
user_id = as_ulid(request.path_params["user_id"])
|
||||
|
||||
user = await db.get(User, id=str(user_id))
|
||||
if not user:
|
||||
return not_found()
|
||||
|
||||
async with db.shared_connection().transaction():
|
||||
# XXX remove user refs from groups and ratings
|
||||
|
||||
await db.remove(user)
|
||||
|
||||
return JSONResponse(asplain(user))
|
||||
|
||||
|
||||
@route("/users/{user_id}", methods=["PATCH"])
|
||||
@requires(["authenticated"])
|
||||
async def modify_user(request):
|
||||
|
||||
user_id = as_ulid(request.path_params["user_id"])
|
||||
|
||||
if is_admin(request):
|
||||
user = await db.get(User, id=str(user_id))
|
||||
|
||||
else:
|
||||
user = await auth_user(request)
|
||||
|
||||
if not user:
|
||||
return not_found()
|
||||
|
||||
is_allowed = user.id == user_id
|
||||
if not is_allowed:
|
||||
return forbidden()
|
||||
|
||||
data = await json_from_body(request)
|
||||
|
||||
if "name" in data:
|
||||
if not is_admin(request):
|
||||
return forbidden("Changing user name is not allowed.")
|
||||
|
||||
# XXX restrict name
|
||||
user.name = data["name"]
|
||||
|
||||
if "imdb_id" in data:
|
||||
if not is_admin(request):
|
||||
return forbidden("Changing IMDb ID is not allowed.")
|
||||
|
||||
# XXX check if imdb_id is well-formed
|
||||
user.imdb_id = data["imdb_id"]
|
||||
|
||||
if "secret" in data:
|
||||
try:
|
||||
secret = b64decode(data["secret"])
|
||||
except:
|
||||
raise HTTPException(422, f"Invalid secret.")
|
||||
|
||||
user.secret = phc_scrypt(secret)
|
||||
|
||||
await db.update(user)
|
||||
|
||||
return JSONResponse(asplain(user))
|
||||
|
||||
|
||||
@route("/users/{user_id}/groups", methods=["POST"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def add_group_to_user(request):
|
||||
|
||||
user_id = as_ulid(request.path_params["user_id"])
|
||||
|
||||
user = await db.get(User, id=str(user_id))
|
||||
if not user:
|
||||
return not_found("User not found")
|
||||
|
||||
(group_id, access) = await json_from_body(request, ["group", "access"])
|
||||
|
||||
group = await db.get(Group, id=str(group_id))
|
||||
if not group:
|
||||
return not_found("Group not found")
|
||||
|
||||
if access not in set("riw"):
|
||||
raise HTTPException(422, f"Invalid access level.")
|
||||
|
||||
user.set_access(group_id, access)
|
||||
await db.update(user)
|
||||
|
||||
return JSONResponse(asplain(user))
|
||||
|
||||
|
||||
@route("/users/{user_id}/ratings")
|
||||
@requires(["private"])
|
||||
async def ratings_for_user(request):
|
||||
request.path_params["user_id"]
|
||||
|
||||
not_implemented()
|
||||
|
||||
|
||||
@route("/users/{user_id}/ratings", methods=["PUT"])
|
||||
@requires("authenticated")
|
||||
async def set_rating_for_user(request):
|
||||
request.path_params["user_id"]
|
||||
|
||||
not_implemented()
|
||||
|
||||
|
||||
@route("/users/_reload_ratings", methods=["POST"])
|
||||
|
|
@ -284,69 +551,49 @@ async def load_imdb_user_ratings(request):
|
|||
@route("/groups")
|
||||
@requires(["authenticated", "admin"])
|
||||
async def list_groups(request):
|
||||
|
||||
groups = await db.get_all(Group)
|
||||
|
||||
return JSONResponse([asplain(g) for g in groups])
|
||||
|
||||
|
||||
@route("/groups", methods=["POST"])
|
||||
@requires(["authenticated", "admin"])
|
||||
async def add_group(request):
|
||||
if not await request.body():
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
raise HTTPException(422, "Invalid JSON content.")
|
||||
|
||||
try:
|
||||
name = data["name"]
|
||||
except KeyError as err:
|
||||
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
|
||||
(name,) = await json_from_body(request, ["name"])
|
||||
|
||||
# XXX restrict name
|
||||
|
||||
secret = secrets.token_bytes()
|
||||
|
||||
group = Group(name=name, secret=phc_scrypt(secret))
|
||||
group = Group(name=name)
|
||||
await db.add(group)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"secret": b64encode(secret),
|
||||
"group": asplain(group),
|
||||
}
|
||||
)
|
||||
return JSONResponse(asplain(group))
|
||||
|
||||
|
||||
@route("/groups/{group_id}/users", methods=["POST"])
|
||||
@requires(["authenticated"])
|
||||
async def add_user_to_group(request):
|
||||
|
||||
group_id = as_ulid(request.path_params["group_id"])
|
||||
group = await db.get(Group, id=str(group_id))
|
||||
|
||||
if not group:
|
||||
return not_found()
|
||||
|
||||
is_allowed = "admin" in request.auth.scopes or phc_compare(
|
||||
secret=request.user.token, phc_string=group.secret
|
||||
)
|
||||
is_allowed = is_admin(request)
|
||||
|
||||
if not is_allowed:
|
||||
user = await auth_user(request)
|
||||
if not user:
|
||||
return not_found("User not found.")
|
||||
|
||||
is_allowed = user.has_access(group_id, "w")
|
||||
|
||||
if not is_allowed:
|
||||
return forbidden()
|
||||
|
||||
if not await request.body():
|
||||
data = {}
|
||||
else:
|
||||
try:
|
||||
data = await request.json()
|
||||
except JSONDecodeError:
|
||||
raise HTTPException(422, "Invalid JSON content.")
|
||||
|
||||
try:
|
||||
name = data["name"]
|
||||
user_id = data["id"]
|
||||
except KeyError as err:
|
||||
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
|
||||
name, user_id = await json_from_body(request, ["name", "id"])
|
||||
|
||||
# XXX check if user exists
|
||||
# XXX restrict name
|
||||
|
|
@ -382,7 +629,7 @@ def create_app():
|
|||
on_startup=[open_connection_pool],
|
||||
on_shutdown=[close_connection_pool],
|
||||
routes=[
|
||||
Mount("/api/v1", routes=route.registered),
|
||||
Mount(f"{config.api_base}v1", routes=route.registered),
|
||||
],
|
||||
middleware=[
|
||||
Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"),
|
||||
|
|
@ -391,6 +638,14 @@ def create_app():
|
|||
backend=BearerAuthBackend(config.api_credentials),
|
||||
on_error=auth_error,
|
||||
),
|
||||
Middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[config.api_cors],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
),
|
||||
Middleware(GZipMiddleware),
|
||||
],
|
||||
exception_handlers={HTTPException: http_exception},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue