Compare commits

...

6 commits

Author SHA1 Message Date
e12c3518d1 update channel after importing
All checks were successful
continuous-integration/drone/push Build is passing
remove emoji from cover titles
2022-06-23 02:43:16 +02:00
a37a7d17b3 add confirmation buttons for deletion 2022-06-23 01:39:39 +02:00
f51df8b362 add hmx pagination 2022-06-23 00:50:29 +02:00
c723377010 add deleting channels/videos
add htmx
2022-06-22 02:21:37 +02:00
88ca3898de dont download all videos when adding channel
add FontAwesome files
2022-06-22 00:37:04 +02:00
eb8e1debdf add video grid 2022-06-21 14:29:44 +02:00
36 changed files with 2388 additions and 891 deletions

View file

@ -5,4 +5,37 @@
max-height: 64px
.video-thumbnail
max-height: 128px
width: 100%
.video-grid
$spacing: 0.5vw
display: grid
grid-row-gap: $spacing
row-gap: $spacing
grid-column-gap: $spacing
column-gap: $spacing
grid-template-columns: repeat(2, minmax(0, 1fr))
grid-column: auto
@include tablet
grid-template-columns: repeat(3,minmax(0,1fr))
@include desktop
grid-template-columns: repeat(4,minmax(0,1fr))
@include widescreen
grid-template-columns: repeat(5,minmax(0,1fr))
@include fullhd
grid-template-columns: repeat(6,minmax(0,1fr))
.video-card
display: flex
flex-direction: column
.video-card-content
padding: 0 0.5vw
&:last-child
padding-bottom: 0.5vw

View file

@ -6,13 +6,6 @@ services:
ports:
- "127.0.0.1:6379:6379"
rq-dashboard:
image: eoranged/rq-dashboard
ports:
- "127.0.0.1:9181:9181"
environment:
RQ_DASHBOARD_REDIS_URL: "redis://redis:6379"
nginx:
image: nginx:1
network_mode: "host"

1899
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,6 @@
"build-cleancss": "cleancss -o ucast/static/bulma/css/style.min.css ucast/static/bulma/css/style.css",
"build-clean": "rimraf ucast/static/bulma/css",
"build-sass": "sass --style expanded --source-map assets/sass/style.sass ucast/static/bulma/css/style.css",
"start": "npm run build-sass -- --watch"
"start": "sass --style expanded --watch assets/sass/style.sass _run/static/bulma/css/style.min.css"
}
}

View file

@ -1,646 +0,0 @@
lockfileVersion: 5.3
specifiers:
autoprefixer: ^10.4.7
bulma: ^0.9.4
clean-css-cli: ^5.6.0
postcss: ^8.4.13
postcss-cli: ^9.1.0
rimraf: ^3.0.2
sass: ^1.51.0
dependencies:
bulma: 0.9.4
devDependencies:
autoprefixer: 10.4.7_postcss@8.4.13
clean-css-cli: 5.6.0
postcss: 8.4.13
postcss-cli: 9.1.0_postcss@8.4.13
rimraf: 3.0.2
sass: 1.51.0
packages:
/@nodelib/fs.scandir/2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
dev: true
/@nodelib/fs.stat/2.0.5:
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
dev: true
/@nodelib/fs.walk/1.2.8:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.13.0
dev: true
/ansi-regex/5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
dev: true
/ansi-styles/4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
dependencies:
color-convert: 2.0.1
dev: true
/anymatch/3.1.2:
resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
engines: {node: '>= 8'}
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
dev: true
/array-union/3.0.1:
resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==}
engines: {node: '>=12'}
dev: true
/autoprefixer/10.4.7_postcss@8.4.13:
resolution: {integrity: sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
dependencies:
browserslist: 4.20.3
caniuse-lite: 1.0.30001339
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
postcss: 8.4.13
postcss-value-parser: 4.2.0
dev: true
/balanced-match/1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
/binary-extensions/2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
dev: true
/brace-expansion/1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
dev: true
/braces/3.0.2:
resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
engines: {node: '>=8'}
dependencies:
fill-range: 7.0.1
dev: true
/browserslist/4.20.3:
resolution: {integrity: sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
caniuse-lite: 1.0.30001339
electron-to-chromium: 1.4.137
escalade: 3.1.1
node-releases: 2.0.4
picocolors: 1.0.0
dev: true
/bulma/0.9.4:
resolution: {integrity: sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==}
dev: false
/caniuse-lite/1.0.30001339:
resolution: {integrity: sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==}
dev: true
/chokidar/3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
engines: {node: '>= 8.10.0'}
dependencies:
anymatch: 3.1.2
braces: 3.0.2
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/clean-css-cli/5.6.0:
resolution: {integrity: sha512-68vorNEG808D1QzeerO9AlwQVTuaR8YSK4aqwIsjJq0wDSyPH11ApHY0O+EQrdEGUZcN+d72v+Nn/gpxjAFewQ==}
engines: {node: '>= 10.12.0'}
hasBin: true
dependencies:
chokidar: 3.5.3
clean-css: 5.3.0
commander: 7.2.0
glob: 7.2.0
dev: true
/clean-css/5.3.0:
resolution: {integrity: sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==}
engines: {node: '>= 10.0'}
dependencies:
source-map: 0.6.1
dev: true
/cliui/7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
dev: true
/color-convert/2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
dependencies:
color-name: 1.1.4
dev: true
/color-name/1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: true
/commander/7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
dev: true
/concat-map/0.0.1:
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
dev: true
/dependency-graph/0.11.0:
resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==}
engines: {node: '>= 0.6.0'}
dev: true
/dir-glob/3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dependencies:
path-type: 4.0.0
dev: true
/electron-to-chromium/1.4.137:
resolution: {integrity: sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==}
dev: true
/emoji-regex/8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true
/escalade/3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
dev: true
/fast-glob/3.2.11:
resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
engines: {node: '>=8.6.0'}
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.5
dev: true
/fastq/1.13.0:
resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
dependencies:
reusify: 1.0.4
dev: true
/fill-range/7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'}
dependencies:
to-regex-range: 5.0.1
dev: true
/fraction.js/4.2.0:
resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
dev: true
/fs-extra/10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
dependencies:
graceful-fs: 4.2.10
jsonfile: 6.1.0
universalify: 2.0.0
dev: true
/fs.realpath/1.0.0:
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
dev: true
/fsevents/2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/get-caller-file/2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: true
/get-stdin/9.0.0:
resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==}
engines: {node: '>=12'}
dev: true
/glob-parent/5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
dependencies:
is-glob: 4.0.3
dev: true
/glob/7.2.0:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
dev: true
/globby/12.2.0:
resolution: {integrity: sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
array-union: 3.0.1
dir-glob: 3.0.1
fast-glob: 3.2.11
ignore: 5.2.0
merge2: 1.4.1
slash: 4.0.0
dev: true
/graceful-fs/4.2.10:
resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
dev: true
/ignore/5.2.0:
resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==}
engines: {node: '>= 4'}
dev: true
/immutable/4.0.0:
resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==}
dev: true
/inflight/1.0.6:
resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
dependencies:
once: 1.4.0
wrappy: 1.0.2
dev: true
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: true
/is-binary-path/2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
dependencies:
binary-extensions: 2.2.0
dev: true
/is-extglob/2.1.1:
resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=}
engines: {node: '>=0.10.0'}
dev: true
/is-fullwidth-code-point/3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
dev: true
/is-glob/4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
dependencies:
is-extglob: 2.1.1
dev: true
/is-number/7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
dev: true
/jsonfile/6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies:
universalify: 2.0.0
optionalDependencies:
graceful-fs: 4.2.10
dev: true
/lilconfig/2.0.5:
resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==}
engines: {node: '>=10'}
dev: true
/merge2/1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
dev: true
/micromatch/4.0.5:
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
engines: {node: '>=8.6'}
dependencies:
braces: 3.0.2
picomatch: 2.3.1
dev: true
/minimatch/3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
brace-expansion: 1.1.11
dev: true
/nanoid/3.3.4:
resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
/node-releases/2.0.4:
resolution: {integrity: sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==}
dev: true
/normalize-path/3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
dev: true
/normalize-range/0.1.2:
resolution: {integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=}
engines: {node: '>=0.10.0'}
dev: true
/once/1.4.0:
resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
dependencies:
wrappy: 1.0.2
dev: true
/path-is-absolute/1.0.1:
resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
engines: {node: '>=0.10.0'}
dev: true
/path-type/4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
dev: true
/picocolors/1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
dev: true
/picomatch/2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
dev: true
/pify/2.3.0:
resolution: {integrity: sha1-7RQaasBDqEnqWISY59yosVMw6Qw=}
engines: {node: '>=0.10.0'}
dev: true
/postcss-cli/9.1.0_postcss@8.4.13:
resolution: {integrity: sha512-zvDN2ADbWfza42sAnj+O2uUWyL0eRL1V+6giM2vi4SqTR3gTYy8XzcpfwccayF2szcUif0HMmXiEaDv9iEhcpw==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
postcss: ^8.0.0
dependencies:
chokidar: 3.5.3
dependency-graph: 0.11.0
fs-extra: 10.1.0
get-stdin: 9.0.0
globby: 12.2.0
picocolors: 1.0.0
postcss: 8.4.13
postcss-load-config: 3.1.4_postcss@8.4.13
postcss-reporter: 7.0.5_postcss@8.4.13
pretty-hrtime: 1.0.3
read-cache: 1.0.0
slash: 4.0.0
yargs: 17.4.1
transitivePeerDependencies:
- ts-node
dev: true
/postcss-load-config/3.1.4_postcss@8.4.13:
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
engines: {node: '>= 10'}
peerDependencies:
postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 2.0.5
postcss: 8.4.13
yaml: 1.10.2
dev: true
/postcss-reporter/7.0.5_postcss@8.4.13:
resolution: {integrity: sha512-glWg7VZBilooZGOFPhN9msJ3FQs19Hie7l5a/eE6WglzYqVeH3ong3ShFcp9kDWJT1g2Y/wd59cocf9XxBtkWA==}
engines: {node: '>=10'}
peerDependencies:
postcss: ^8.1.0
dependencies:
picocolors: 1.0.0
postcss: 8.4.13
thenby: 1.3.4
dev: true
/postcss-value-parser/4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
dev: true
/postcss/8.4.13:
resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.4
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/pretty-hrtime/1.0.3:
resolution: {integrity: sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=}
engines: {node: '>= 0.8'}
dev: true
/queue-microtask/1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/read-cache/1.0.0:
resolution: {integrity: sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=}
dependencies:
pify: 2.3.0
dev: true
/readdirp/3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
picomatch: 2.3.1
dev: true
/require-directory/2.1.1:
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
engines: {node: '>=0.10.0'}
dev: true
/reusify/1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
/rimraf/3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
hasBin: true
dependencies:
glob: 7.2.0
dev: true
/run-parallel/1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
queue-microtask: 1.2.3
dev: true
/sass/1.51.0:
resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==}
engines: {node: '>=12.0.0'}
hasBin: true
dependencies:
chokidar: 3.5.3
immutable: 4.0.0
source-map-js: 1.0.2
dev: true
/slash/4.0.0:
resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==}
engines: {node: '>=12'}
dev: true
/source-map-js/1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
dev: true
/source-map/0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
dev: true
/string-width/4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: true
/strip-ansi/6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
dependencies:
ansi-regex: 5.0.1
dev: true
/thenby/1.3.4:
resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==}
dev: true
/to-regex-range/5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
dependencies:
is-number: 7.0.0
dev: true
/universalify/2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
dev: true
/wrap-ansi/7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: true
/wrappy/1.0.2:
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
dev: true
/y18n/5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
dev: true
/yaml/1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
dev: true
/yargs-parser/21.0.1:
resolution: {integrity: sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==}
engines: {node: '>=12'}
dev: true
/yargs/17.4.1:
resolution: {integrity: sha512-WSZD9jgobAg3ZKuCQZSa3g9QOJeCCqLoLAykiWgmXnDo9EPnn4RPf5qVTtzgOx66o6/oqhcA5tHtJXpG8pMt3g==}
engines: {node: '>=12'}
dependencies:
cliui: 7.0.4
escalade: 3.1.1
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.0.1
dev: true

103
poetry.lock generated
View file

@ -128,14 +128,14 @@ Pillow = "*"
[[package]]
name = "coverage"
version = "6.4"
version = "6.4.1"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_version < \"3.11\" and extra == \"toml\""}
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
@ -201,6 +201,17 @@ python-versions = ">=3.7"
[package.dependencies]
django = ">=2.2"
[[package]]
name = "django-htmx"
version = "1.12.0"
description = "Extensions for using Django with htmx."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "fakeredis"
version = "1.8"
@ -743,7 +754,7 @@ websockets = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "1d1799636eadf391bd9545eb3d8750a1e882cdb9b84d47395284d90a3cfeb609"
content-hash = "5c24c22351390a472cd905bf1d08890314441ef590c46a64e7940fb180f909a2"
[metadata.files]
asgiref = [
@ -935,47 +946,47 @@ colorthief = [
{file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"},
]
coverage = [
{file = "coverage-6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50ed480b798febce113709846b11f5d5ed1e529c88d8ae92f707806c50297abf"},
{file = "coverage-6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26f8f92699756cb7af2b30720de0c5bb8d028e923a95b6d0c891088025a1ac8f"},
{file = "coverage-6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60c2147921da7f4d2d04f570e1838db32b95c5509d248f3fe6417e91437eaf41"},
{file = "coverage-6.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:750e13834b597eeb8ae6e72aa58d1d831b96beec5ad1d04479ae3772373a8088"},
{file = "coverage-6.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af5b9ee0fc146e907aa0f5fb858c3b3da9199d78b7bb2c9973d95550bd40f701"},
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a022394996419142b33a0cf7274cb444c01d2bb123727c4bb0b9acabcb515dea"},
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5a78cf2c43b13aa6b56003707c5203f28585944c277c1f3f109c7b041b16bd39"},
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9229d074e097f21dfe0643d9d0140ee7433814b3f0fc3706b4abffd1e3038632"},
{file = "coverage-6.4-cp310-cp310-win32.whl", hash = "sha256:fb45fe08e1abc64eb836d187b20a59172053999823f7f6ef4f18a819c44ba16f"},
{file = "coverage-6.4-cp310-cp310-win_amd64.whl", hash = "sha256:3cfd07c5889ddb96a401449109a8b97a165be9d67077df6802f59708bfb07720"},
{file = "coverage-6.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:03014a74023abaf5a591eeeaf1ac66a73d54eba178ff4cb1fa0c0a44aae70383"},
{file = "coverage-6.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82f2cd69c71698152e943f4a5a6b83a3ab1db73b88f6e769fabc86074c3b08"},
{file = "coverage-6.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b546cf2b1974ddc2cb222a109b37c6ed1778b9be7e6b0c0bc0cf0438d9e45a6"},
{file = "coverage-6.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc173f1ce9ffb16b299f51c9ce53f66a62f4d975abe5640e976904066f3c835d"},
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c53ad261dfc8695062fc8811ac7c162bd6096a05a19f26097f411bdf5747aee7"},
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:eef5292b60b6de753d6e7f2d128d5841c7915fb1e3321c3a1fe6acfe76c38052"},
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:543e172ce4c0de533fa892034cce260467b213c0ea8e39da2f65f9a477425211"},
{file = "coverage-6.4-cp37-cp37m-win32.whl", hash = "sha256:00c8544510f3c98476bbd58201ac2b150ffbcce46a8c3e4fb89ebf01998f806a"},
{file = "coverage-6.4-cp37-cp37m-win_amd64.whl", hash = "sha256:b84ab65444dcc68d761e95d4d70f3cfd347ceca5a029f2ffec37d4f124f61311"},
{file = "coverage-6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d548edacbf16a8276af13063a2b0669d58bbcfca7c55a255f84aac2870786a61"},
{file = "coverage-6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:033ebec282793bd9eb988d0271c211e58442c31077976c19c442e24d827d356f"},
{file = "coverage-6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742fb8b43835078dd7496c3c25a1ec8d15351df49fb0037bffb4754291ef30ce"},
{file = "coverage-6.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55fae115ef9f67934e9f1103c9ba826b4c690e4c5bcf94482b8b2398311bf9c"},
{file = "coverage-6.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cd698341626f3c77784858427bad0cdd54a713115b423d22ac83a28303d1d95"},
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62d382f7d77eeeaff14b30516b17bcbe80f645f5cf02bb755baac376591c653c"},
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:016d7f5cf1c8c84f533a3c1f8f36126fbe00b2ec0ccca47cc5731c3723d327c6"},
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:69432946f154c6add0e9ede03cc43b96e2ef2733110a77444823c053b1ff5166"},
{file = "coverage-6.4-cp38-cp38-win32.whl", hash = "sha256:83bd142cdec5e4a5c4ca1d4ff6fa807d28460f9db919f9f6a31babaaa8b88426"},
{file = "coverage-6.4-cp38-cp38-win_amd64.whl", hash = "sha256:4002f9e8c1f286e986fe96ec58742b93484195defc01d5cc7809b8f7acb5ece3"},
{file = "coverage-6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4f52c272fdc82e7c65ff3f17a7179bc5f710ebc8ce8a5cadac81215e8326740"},
{file = "coverage-6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5578efe4038be02d76c344007b13119b2b20acd009a88dde8adec2de4f630b5"},
{file = "coverage-6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8099ea680201c2221f8468c372198ceba9338a5fec0e940111962b03b3f716a"},
{file = "coverage-6.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a00441f5ea4504f5abbc047589d09e0dc33eb447dc45a1a527c8b74bfdd32c65"},
{file = "coverage-6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e76bd16f0e31bc2b07e0fb1379551fcd40daf8cdf7e24f31a29e442878a827c"},
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8d2e80dd3438e93b19e1223a9850fa65425e77f2607a364b6fd134fcd52dc9df"},
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:341e9c2008c481c5c72d0e0dbf64980a4b2238631a7f9780b0fe2e95755fb018"},
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:21e6686a95025927775ac501e74f5940cdf6fe052292f3a3f7349b0abae6d00f"},
{file = "coverage-6.4-cp39-cp39-win32.whl", hash = "sha256:968ed5407f9460bd5a591cefd1388cc00a8f5099de9e76234655ae48cfdbe2c3"},
{file = "coverage-6.4-cp39-cp39-win_amd64.whl", hash = "sha256:e35217031e4b534b09f9b9a5841b9344a30a6357627761d4218818b865d45055"},
{file = "coverage-6.4-pp36.pp37.pp38-none-any.whl", hash = "sha256:e637ae0b7b481905358624ef2e81d7fb0b1af55f5ff99f9ba05442a444b11e45"},
{file = "coverage-6.4.tar.gz", hash = "sha256:727dafd7f67a6e1cad808dc884bd9c5a2f6ef1f8f6d2f22b37b96cb0080d4f49"},
{file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"},
{file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"},
{file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"},
{file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"},
{file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"},
{file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"},
{file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"},
{file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"},
{file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"},
{file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"},
{file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"},
{file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"},
{file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"},
{file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"},
{file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"},
{file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"},
{file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"},
{file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"},
{file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"},
{file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"},
{file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"},
{file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"},
{file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"},
{file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"},
{file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"},
{file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"},
{file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"},
{file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"},
{file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"},
{file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"},
{file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"},
{file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"},
{file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"},
{file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"},
{file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"},
{file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"},
{file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"},
{file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"},
{file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"},
{file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"},
{file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"},
]
croniter = [
{file = "croniter-1.3.5-py2.py3-none-any.whl", hash = "sha256:4f72faca42c00beb6e30907f1315145f43dfbe5ec0ad4ada24b4c0d57b86a33a"},
@ -997,6 +1008,10 @@ django-bulma = [
{file = "django-bulma-0.8.3.tar.gz", hash = "sha256:b794b4e64f482de77f376451f7cd8b3c8448eb68e5a24c51b9190625a08b0b30"},
{file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"},
]
django-htmx = [
{file = "django-htmx-1.12.0.tar.gz", hash = "sha256:33750914939e8fbd214e36fb0976003e06f4cadc92007cbe5c49f59e32e58de6"},
{file = "django_htmx-1.12.0-py3-none-any.whl", hash = "sha256:e8351b9251642a5a550a18c6958727ea9b33574bb412b1900fa5ab0d5dd9db40"},
]
fakeredis = [
{file = "fakeredis-1.8-py3-none-any.whl", hash = "sha256:65dcd78c0cd29d17daccce9f58698f6ab61ad7a404eab373fcad2b76fe8db03d"},
{file = "fakeredis-1.8.tar.gz", hash = "sha256:cbf8d74ae06672d40b2fa88b9ee4f1d6efd56b06b2e7f0be2c639647f00643f1"},

View file

@ -28,6 +28,7 @@ mutagen = "^1.45.1"
rq = "^1.10.1"
rq-scheduler = "^0.11.0"
pycryptodomex = "^3.14.1"
django-htmx = "^1.12.0"
[tool.poetry.dev-dependencies]
pytest = "^7.1.1"

View file

@ -115,8 +115,10 @@ def reset(c):
shutil.rmtree(DIR_DOWNLOAD)
if FILE_DB.exists():
os.remove(FILE_DB)
os.makedirs(DIR_DOWNLOAD, exist_ok=True)
migrate(c)
create_testuser(c)
collectstatic(c)
@task

View file

@ -2,6 +2,7 @@ import re
from xml.sax import saxutils
from django import http
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import Feed, add_domain
from django.utils import feedgenerator
@ -155,7 +156,9 @@ class UcastFeed(Feed):
image_url=self.full_link_url(request, f"/files/avatar/{channel.slug}.jpg"),
)
for video in channel.video_set.order_by("-published"):
for video in channel.video_set.order_by("-published")[
: settings.FEED_MAX_ITEMS
]:
feed.add_item(
title=video.title,
link=video.get_absolute_url(),
@ -187,6 +190,6 @@ class UcastFeed(Feed):
request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3"
),
length=str(item.download_size),
mime_type="audio/mpg",
mime_type="audio/mpeg",
)
return [enc]

View file

@ -3,3 +3,7 @@ from django import forms
class AddChannelForm(forms.Form):
channel_str = forms.CharField(label="Channel-ID / URL")
class DeleteVideoForm(forms.Form):
id = forms.IntegerField()

View file

@ -1,4 +1,4 @@
# Generated by Django 4.0.4 on 2022-05-29 21:01
# Generated by Django 4.0.4 on 2022-06-21 23:07
import django.contrib.auth.models
import django.contrib.auth.validators
@ -28,15 +28,19 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("channel_id", models.CharField(max_length=30)),
("channel_id", models.CharField(db_index=True, max_length=30)),
("name", models.CharField(max_length=100)),
("slug", models.CharField(max_length=100)),
("slug", models.CharField(db_index=True, max_length=100)),
("description", models.TextField()),
("subscribers", models.CharField(max_length=20, null=True)),
("active", models.BooleanField(default=True)),
("skip_livestreams", models.BooleanField(default=True)),
("skip_shorts", models.BooleanField(default=True)),
("avatar_url", models.CharField(max_length=250, null=True)),
(
"last_update",
models.DateTimeField(default=django.utils.timezone.now),
),
],
),
migrations.CreateModel(
@ -51,9 +55,9 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("video_id", models.CharField(max_length=30)),
("video_id", models.CharField(db_index=True, max_length=30)),
("title", models.CharField(max_length=200)),
("slug", models.CharField(max_length=209)),
("slug", models.CharField(db_index=True, max_length=209)),
("published", models.DateTimeField()),
("downloaded", models.DateTimeField(null=True)),
("description", models.TextField()),
@ -61,6 +65,7 @@ class Migration(migrations.Migration):
("is_livestream", models.BooleanField(default=False)),
("is_short", models.BooleanField(default=False)),
("download_size", models.IntegerField(null=True)),
("is_deleted", models.BooleanField(default=False)),
(
"channel",
models.ForeignKey(

View file

@ -4,6 +4,7 @@ import datetime
from Cryptodome import Random
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils import timezone
from ucast.service import util
@ -31,15 +32,16 @@ def _get_unique_slug(
class Channel(models.Model):
channel_id = models.CharField(max_length=30)
channel_id = models.CharField(max_length=30, db_index=True)
name = models.CharField(max_length=100)
slug = models.CharField(max_length=100)
slug = models.CharField(max_length=100, db_index=True)
description = models.TextField()
subscribers = models.CharField(max_length=20, null=True)
active = models.BooleanField(default=True)
skip_livestreams = models.BooleanField(default=True)
skip_shorts = models.BooleanField(default=True)
avatar_url = models.CharField(max_length=250, null=True)
last_update = models.DateTimeField(default=timezone.now)
@classmethod
def get_new_slug(cls, name: str) -> str:
@ -73,9 +75,9 @@ class Channel(models.Model):
class Video(models.Model):
video_id = models.CharField(max_length=30)
video_id = models.CharField(max_length=30, db_index=True)
title = models.CharField(max_length=200)
slug = models.CharField(max_length=209)
slug = models.CharField(max_length=209, db_index=True)
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
published = models.DateTimeField()
downloaded = models.DateTimeField(null=True)
@ -84,6 +86,7 @@ class Video(models.Model):
is_livestream = models.BooleanField(default=False)
is_short = models.BooleanField(default=False)
download_size = models.IntegerField(null=True)
is_deleted = models.BooleanField(default=False)
@classmethod
def get_new_slug(cls, title: str, date: datetime.date, channel_id: str) -> str:

View file

@ -0,0 +1,72 @@
import shutil
from ucast.models import Channel, Video
from ucast.service import storage, util, youtube
class ChannelAlreadyExistsException(Exception):
def __init__(self, *args: object) -> None:
super().__init__("channel already exists", *args)
def download_channel_avatar(channel: Channel):
store = storage.Storage()
channel_folder = store.get_or_create_channel_folder(channel.slug)
util.download_image_file(channel.avatar_url, channel_folder.file_avatar)
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
def create_channel(channel_str: str) -> Channel:
if youtube.CHANID_REGEX.match(channel_str):
if Channel.objects.filter(channel_id=channel_str).exists():
raise ChannelAlreadyExistsException()
channel_url = youtube.channel_url_from_str(channel_str)
channel_data = youtube.get_channel_metadata(channel_url)
if Channel.objects.filter(channel_id=channel_data.id).exists():
raise ChannelAlreadyExistsException()
channel_slug = Channel.get_new_slug(channel_data.name)
channel = Channel(
channel_id=channel_data.id,
name=channel_data.name,
slug=channel_slug,
description=channel_data.description,
subscribers=channel_data.subscribers,
avatar_url=channel_data.avatar_url,
)
download_channel_avatar(channel)
channel.save()
return channel
def delete_video(id: int):
video = Video.objects.get(id=id)
store = storage.Storage()
channel_folder = store.get_channel_folder(video.channel.slug)
util.remove_if_exists(channel_folder.get_audio(video.slug))
util.remove_if_exists(channel_folder.get_cover(video.slug))
util.remove_if_exists(channel_folder.get_thumbnail(video.slug))
util.remove_if_exists(channel_folder.get_thumbnail(video.slug, True))
video.is_deleted = True
video.downloaded = None
video.download_size = None
video.save()
def delete_channel(id: int):
channel = Channel.objects.get(id=id)
store = storage.Storage()
channel_folder = store.get_channel_folder(channel.slug)
shutil.rmtree(channel_folder.dir_root)
channel.delete()

View file

@ -9,7 +9,7 @@ from colorthief import ColorThief
from fonts.ttf import SourceSansPro
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
from ucast.service import typ
from ucast.service import typ, util
COVER_STYLE_BLUR = "blur"
COVER_STYLE_GRADIENT = "gradient"
@ -105,8 +105,9 @@ def _draw_text_box(
x_tl, y_tl, x_br, y_br = box
height = y_br - y_tl
width = x_br - x_tl
sanitized_text = util.strip_emoji(text)
lines = _split_text(height, width, text, font, line_spacing)
lines = _split_text(height, width, sanitized_text, font, line_spacing)
y_start = y_tl
if vertical_center:

View file

@ -1,6 +1,8 @@
import datetime
import io
import json
import os
import re
from pathlib import Path
from typing import Any, Union
from urllib import parse
@ -13,6 +15,23 @@ from PIL import Image
AVATAR_SM_WIDTH = 100
THUMBNAIL_SM_WIDTH = 360
EMOJI_PATTERN = re.compile(
"["
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F600-\U0001F64F" # emoticons
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F700-\U0001F77F" # alchemical symbols
"\U0001F780-\U0001F7FF" # Geometric Shapes Extended
"\U0001F800-\U0001F8FF" # Supplemental Arrows-C
"\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs
"\U0001FA00-\U0001FA6F" # Chess Symbols
"\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A
"\U00002702-\U000027B0" # Dingbats
"\U000024C2-\U0001F251"
"]+"
)
def download_file(url: str, download_path: Path):
r = requests.get(url, allow_redirects=True)
@ -163,3 +182,13 @@ def add_key_to_url(url: str, key: str):
query["key"] = key
url_parts[4] = _urlencode(query)
return parse.urlunparse(url_parts)
def remove_if_exists(file: Path):
if os.path.isfile(file):
os.remove(file)
def strip_emoji(str_in: str) -> str:
stripped = EMOJI_PATTERN.sub("", str_in)
return re.sub(" +", " ", stripped)

View file

@ -133,6 +133,14 @@ def get_video_details(video_id: str) -> VideoDetails:
def download_audio(
video_id: str, download_path: Path, sponsorblock=False
) -> VideoDetails:
"""
Download the audio track from a YouTube video save it at the given filepath.
:param video_id: YouTube video ID
:param download_path: Download path
:param sponsorblock: Enable Sponsorblock
:return: VideoDetails
"""
tmp_dld_file = download_path.with_suffix(".dld" + download_path.suffix)
ydl_params = {
@ -160,7 +168,8 @@ def download_audio(
# extract_info downloads the video and returns its metadata
info = ydl.extract_info(video_id)
shutil.move(tmp_dld_file, download_path)
downloaded_file = info["requested_downloads"][0]["filepath"]
shutil.move(downloaded_file, download_path)
return VideoDetails.from_vinfo(info)

File diff suppressed because one or more lines are too long

2
ucast/static/ucast/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,9 @@
const confirmButtons = document.getElementsByClassName("dialog-confirm")
for(let btn of confirmButtons) {
btn.addEventListener("click", function(e) {
const result = window.confirm(btn.getAttribute("confirm-msg"));
if (!result) {
e.preventDefault();
}
})
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,73 +1,47 @@
import mimetypes
import os
from django.db.models import ObjectDoesNotExist
from django.utils import timezone
from ucast import queue
from ucast.models import Channel, Video
from ucast.service import cover, storage, util, videoutil, youtube
def _get_or_create_channel(channel_str: str) -> Channel:
if youtube.CHANID_REGEX.match(channel_str):
try:
return Channel.objects.get(channel_id=channel_str)
except Channel.DoesNotExist:
pass
channel_url = youtube.channel_url_from_str(channel_str)
channel_data = youtube.get_channel_metadata(channel_url)
try:
return Channel.objects.get(channel_id=channel_data.id)
except Channel.DoesNotExist:
pass
channel_slug = Channel.get_new_slug(channel_data.name)
store = storage.Storage()
channel_folder = store.get_or_create_channel_folder(channel_slug)
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
channel = Channel(
channel_id=channel_data.id,
name=channel_data.name,
slug=channel_slug,
description=channel_data.description,
subscribers=channel_data.subscribers,
)
channel.save()
return channel
from ucast.service import controller, cover, storage, util, videoutil, youtube
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
if Video.objects.filter(video_id=vid.id).exists():
return
# Create video object if it does not exist
try:
video = Video.objects.get(video_id=vid.id)
except ObjectDoesNotExist:
details = youtube.get_video_details(vid.id)
details = youtube.get_video_details(vid.id)
# Dont load active livestreams
if details.is_currently_live:
return
# Dont load active livestreams
if details.is_currently_live:
return
slug = Video.get_new_slug(
details.title, details.published.date(), channel.channel_id
)
slug = Video.get_new_slug(
details.title, details.published.date(), channel.channel_id
)
video = Video(
video_id=details.id,
title=details.title,
slug=slug,
channel=channel,
published=details.published,
description=details.description,
duration=details.duration,
is_livestream=details.is_livestream,
is_short=details.is_short,
)
video.save()
video = Video(
video_id=details.id,
title=details.title,
slug=slug,
channel=channel,
published=details.published,
description=details.description,
duration=details.duration,
is_livestream=details.is_livestream,
is_short=details.is_short,
)
video.save()
if channel.should_download(video):
if (
video.downloaded is None
and video.is_deleted is False
and channel.should_download(video)
):
queue.enqueue(download_video, video)
@ -89,6 +63,10 @@ def download_video(video: Video):
youtube.download_thumbnail(details, tn_path)
util.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True))
cover_file = channel_folder.get_cover(video.slug)
if not os.path.isfile(channel_folder.file_avatar):
controller.download_channel_avatar(video.channel)
cover.create_cover_file(
tn_path,
channel_folder.file_avatar,
@ -109,25 +87,10 @@ def download_video(video: Video):
video.downloaded = timezone.now()
video.download_size = os.path.getsize(audio_file)
video.download_type, _ = mimetypes.guess_type(audio_file)
video.save()
def import_channel(channel_str: str, limit: int = None):
"""
Add a new channel to ucast and download all existing videos.
:param channel_str: YT-Channel-ID / URL
:param limit: Maximum number of videos to download
"""
channel = _get_or_create_channel(channel_str)
if limit == 0:
return
for vid in youtube.get_channel_videos_from_scraper(channel.channel_id, limit):
_load_scraped_video(vid, channel)
def update_channel(channel: Channel):
"""Update a single channel from its RSS feed"""
videos = youtube.get_channel_videos_from_feed(channel.channel_id)
@ -135,6 +98,9 @@ def update_channel(channel: Channel):
for vid in videos:
_load_scraped_video(vid, channel)
channel.last_update = timezone.now()
channel.save()
def update_channels():
"""

View file

@ -6,8 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock title %}</title>
{% block css %}
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.1.1/css/all.css" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'ucast/css/fontawesome.css' %}">
<link rel="stylesheet" href="{% static 'bulma/css/style.min.css' %}">
<script src="{% static 'ucast/js/htmx.min.js' %}" defer></script>
<script src="{% static 'ucast/js/ucast.js' %}" defer></script>
{% block extra_css %}{% endblock extra_css %}
{% endblock css %}
</head>

View file

@ -6,11 +6,26 @@
<h1 class="title">Channels</h1>
<div class="box">
<form action="" method="post">
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endif %}
<form method="post">
{% csrf_token %}
<div class="field has-addons">
<div class="control is-flex-grow-1">
<input name="channel_str" required class="input" type="text" placeholder="Channel-ID / URL">
<input name="channel_str" required class="input" type="text"
placeholder="Channel-ID / URL">
</div>
<div class="control">
<button type="submit" class="button is-primary">
@ -24,13 +39,19 @@
{% for channel in channels %}
<div class="box is-flex">
<div class="is-flex">
<a href="/channel/{{ channel.slug }}">
<a href="{% url 'videos' channel.slug %}">
<img class="channel-icon" src="/files/avatar/{{ channel.slug }}.webp?sm">
</a>
</div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="/channel/{{ channel.slug }}">{{ channel.name }}</a>
<div class="field has-addons">
<div class="control">
<a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
class="button">
<i class="fas fa-feed"></i>
</a>
</div>
<div class="control is-flex-grow-1">
<input class="input" type="text"
value="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"

View file

@ -4,91 +4,81 @@
{% block content %}
<div class="level">
<h1 class="title">{{ channel.name }}</h1>
<div>
<h1 class="title">{{ channel.name }}</h1>
<p>Last update: {{ channel.last_update }}</p>
</div>
<div class="tags">
<span class="tag"><i
class="fas fa-user-group"></i>&nbsp; {{ channel.subscribers }}</span>
class="fas fa-user-group"></i>&nbsp; {{ channel.subscribers }}</span>
<span class="tag"><i
class="fas fa-video"></i>&nbsp; {{ channel.video_set.count }}</span>
class="fas fa-video"></i>&nbsp; {{ videos|length }}</span>
<span class="tag"><i
class="fas fa-database"></i>&nbsp; {{ channel.download_size|filesizeformat }}</span>
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
class="fa-brands fa-youtube"></i>&nbsp; {{ channel.channel_id }}</a>
</div>
<div class="field has-addons">
<div class="control">
<a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
class="button">
<i class="fas fa-rss"></i>
</a>
</div>
<div class="control">
<button class="button is-success">
<i class="fas fa-power-off"></i>
</button>
</div>
<div class="control">
<button class="button is-info">
<i class="fas fa-edit"></i>
</button>
</div>
<div class="control">
<button class="button is-danger">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<div class="level">
</div>
{% for video in videos %}
<div class="box is-flex">
<div class="is-flex">
<a href="{{ video.get_absolute_url }}" target="_blank">
<img class="video-thumbnail"
src="/files/thumbnail/{{ channel.slug }}/{{ video.slug }}.webp?sm">
</a>
</div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="{{ video.get_absolute_url }}"
target="_blank">{{ video.title }}</a>
<div class="tags">
<span class="tag"><i
class="fas fa-calendar"></i>&nbsp; {{ video.published|date }}</span>
<span class="tag"><i
class="fas fa-database"></i>&nbsp; {{ video.download_size|filesizeformat }}</span>
<a class="tag" href="{{ video.get_absolute_url }}" target="_blank"><i
class="fa-brands fa-youtube"></i>&nbsp; {{ video.video_id }}</a>
</div>
</div>
<form method="post">
<div class="field has-addons">
{% if video.downloaded %}
<div class="control">
<a class="button is-success"
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3"
target="_blank">
<i class="fas fa-play"></i>
</a>
</div>
<div class="control">
<button class="button is-danger">
<i class="fas fa-trash"></i>
<div class="control">
<a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
class="button">
<i class="fas fa-rss"></i>
</a>
</div>
<div class="control">
{% if channel.active %}
<button type="submit" name="deactivate" class="button is-success">
<i class="fas fa-power-off"></i>
</button>
</div>
{% else %}
<div class="control">
<button class="button is-primary">
<i class="fas fa-download"></i>
{% else %}
<button type="submit" name="activate" class="button is-danger">
<i class="fas fa-power-off"></i>
</button>
</div>
{% endif %}
{% endif %}
</div>
<!--
<div class="control">
<button class="button is-info">
<i class="fas fa-edit"></i>
</button>
</div>
-->
<div class="control">
<button type="submit" name="delete_channel" class="button is-danger dialog-confirm"
confirm-msg="Do you want to delete the channel '{{ channel.name }}' including {{ videos|length }} videos?">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
{% endfor %}
{% csrf_token %}
</form>
</div>
<div class="video-grid">
{% if not videos %}
<p>No videos</p>
{% endif %}
{% include "ucast/videos_items.html" %}
</div>
{% if videos.has_previous or videos.has_next %}
<noscript>
<nav class="pagination is-centered mt-4" role="navigation" aria-label="pagination">
{% if videos.has_previous %}
<a class="pagination-previous" href="?page={{ videos.previous_page_number }}">Previous</a>
{% else %}
<a class="pagination-previous" disabled>Previous</a>
{% endif %}
{% if videos.has_next %}
<a class="pagination-next" href="?page={{ videos.next_page_number }}">Next
page</a>
{% else %}
<a class="pagination-previous" disabled>Previous</a>
{% endif %}
</nav>
</noscript>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,48 @@
{% for video in videos %}
<div class="card video-card"
{% if forloop.last and videos.has_next %}
hx-get="?page={{ videos.next_page_number }}"
hx-trigger="revealed"
hx-swap="afterend"
{% endif %}>
<a href="{{ video.get_absolute_url }}" target="_blank">
<img class="video-thumbnail"
src="/files/thumbnail/{{ channel.slug }}/{{ video.slug }}.webp?sm">
</a>
<div class="video-card-content is-flex-grow-1">
<a href="{{ video.get_absolute_url }}">{{ video.title }}</a>
</div>
<div class="video-card-content">
<div class="level">
<span class="tag">
<i
class="fas fa-calendar"></i>&nbsp; {{ video.published|date:"SHORT_DATE_FORMAT" }}
</span>
<div class="field has-addons">
<div class="control">
<a class="button is-small is-success"
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3"
target="_blank">
<i class="fas fa-play"></i>
</a>
</div>
<div class="control">
<form method="post">
{% csrf_token %}
<input type="hidden" name="id" value="{{ video.id }}">
<button type="submit" name="delete_video"
class="button is-small is-danger dialog-confirm"
confirm-msg="Do you want to delete the video '{{ video.title }}'?">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}

View file

@ -28,27 +28,6 @@ def test_download_video(download_dir, rq_queue):
assert os.path.isfile(cf.get_thumbnail(VIDEO_SLUG_INTRO, True))
@pytest.mark.django_db
def test_import_channel(
download_dir, rq_queue, mock_get_video_details, mock_download_audio
):
# Remove 2 videos from the database so they can be imported
Video.objects.get(video_id="ZPxEr4YdWt8").delete()
Video.objects.get(video_id="_I5IFObm_-k").delete()
job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV)
assert job.is_finished
mock_download_audio.assert_any_call(
"_I5IFObm_-k",
download_dir / "ThetaDev" / "20180331_Easter_special_3D_printed_Bunny.mp3",
)
mock_download_audio.assert_any_call(
"ZPxEr4YdWt8",
download_dir / "ThetaDev" / "20190602_ThetaDev_Embedded_World_2019.mp3",
)
@pytest.mark.django_db
def test_update_channel(
download_dir, rq_queue, mock_get_video_details, mock_download_audio

View file

@ -4,7 +4,7 @@ from ucast import views
urlpatterns = [
path("", views.home),
path("channel/<str:channel>", views.videos),
path("channel/<str:channel>", views.videos, name="videos"),
path("feed/<str:channel>", views.podcast_feed),
path("files/audio/<str:channel>/<str:video>", views.audio),
path("files/cover/<str:channel>/<str:video>", views.cover),

View file

@ -8,13 +8,15 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import add_domain
from django.core.paginator import Paginator
from django.middleware.http import ConditionalGetMiddleware
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import decorator_from_middleware
from ucast import feed, forms, queue
from ucast.models import Channel, User, Video
from ucast.service import storage
from ucast.service import controller, storage
from ucast.tasks import download
@ -23,12 +25,22 @@ def home(request: http.HttpRequest):
channels = Channel.objects.all()
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
form = forms.AddChannelForm()
if request.method == "POST":
form = forms.AddChannelForm(request.POST)
if form.is_valid():
channel_str = form.cleaned_data["channel_str"]
queue.enqueue(download.import_channel, channel_str)
try:
channel = controller.create_channel(channel_str)
queue.enqueue(download.update_channel, channel)
except ValueError:
form.add_error("channel_str", "Channel URL invalid")
except controller.ChannelAlreadyExistsException:
form.add_error("channel_str", "Channel already exists")
if form.is_valid():
return http.HttpResponseRedirect(reverse(home))
return render(
request,
@ -36,7 +48,7 @@ def home(request: http.HttpRequest):
{
"channels": channels,
"site_url": site_url,
"add_channel_form": forms.AddChannelForm,
"form": form,
},
)
@ -44,14 +56,49 @@ def home(request: http.HttpRequest):
@login_required
def videos(request: http.HttpRequest, channel: str):
chan = Channel.objects.get(slug=channel)
vids = Video.objects.filter(channel=chan).order_by("-published")
if request.method == "POST":
if "activate" in request.POST:
chan.active = True
chan.save()
elif "deactivate" in request.POST:
chan.active = False
chan.save()
elif "delete_video" in request.POST:
form = forms.DeleteVideoForm(request.POST)
if form.is_valid():
id = form.cleaned_data["id"]
controller.delete_video(id)
else:
return http.HttpResponseBadRequest()
elif "delete_channel" in request.POST:
controller.delete_channel(chan.id)
return http.HttpResponseRedirect(reverse(home))
else:
return http.HttpResponseBadRequest()
return http.HttpResponseRedirect(reverse(videos, args=[channel]))
vids = Video.objects.filter(channel=chan, downloaded__isnull=False).order_by(
"-published"
)
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
page_number = request.GET.get("page")
videos_p = Paginator(vids, 20)
template_name = "ucast/videos.html"
if request.htmx:
template_name = "ucast/videos_items.html"
return render(
request,
"ucast/videos.html",
{"videos": vids, "channel": chan, "site_url": site_url},
template_name,
{
"videos": videos_p.get_page(page_number),
"channel": chan,
"site_url": site_url,
},
)

View file

@ -98,6 +98,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"bulma",
"django_htmx",
]
MIDDLEWARE = [
@ -109,6 +110,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
ROOT_URLCONF = "ucast_project.urls"
@ -218,6 +220,7 @@ REDIS_QUEUE_TIMEOUT = get_env("REDIS_QUEUE_TIMEOUT", 600)
REDIS_QUEUE_RESULT_TTL = 600
YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900)
FEED_MAX_ITEMS = get_env("FEED_MAX_ITEMS", 100)
INTERNAL_FILES_ROOT = get_env("INTERNAL_FILES_ROOT", "internal_files")
INTERNAL_REDIRECT_HEADER = get_env("INTERNAL_REDIRECT_HEADER", "X-Accel-Redirect")