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 max-height: 64px
.video-thumbnail .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: ports:
- "127.0.0.1:6379:6379" - "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: nginx:
image: nginx:1 image: nginx:1
network_mode: "host" 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-cleancss": "cleancss -o ucast/static/bulma/css/style.min.css ucast/static/bulma/css/style.css",
"build-clean": "rimraf ucast/static/bulma/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", "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]] [[package]]
name = "coverage" name = "coverage"
version = "6.4" version = "6.4.1"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.dependencies] [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] [package.extras]
toml = ["tomli"] toml = ["tomli"]
@ -201,6 +201,17 @@ python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
django = ">=2.2" 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]] [[package]]
name = "fakeredis" name = "fakeredis"
version = "1.8" version = "1.8"
@ -743,7 +754,7 @@ websockets = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "1d1799636eadf391bd9545eb3d8750a1e882cdb9b84d47395284d90a3cfeb609" content-hash = "5c24c22351390a472cd905bf1d08890314441ef590c46a64e7940fb180f909a2"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
@ -935,47 +946,47 @@ colorthief = [
{file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"}, {file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"},
] ]
coverage = [ coverage = [
{file = "coverage-6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50ed480b798febce113709846b11f5d5ed1e529c88d8ae92f707806c50297abf"}, {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"},
{file = "coverage-6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26f8f92699756cb7af2b30720de0c5bb8d028e923a95b6d0c891088025a1ac8f"}, {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"},
{file = "coverage-6.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60c2147921da7f4d2d04f570e1838db32b95c5509d248f3fe6417e91437eaf41"}, {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"},
{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.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"},
{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.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-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a022394996419142b33a0cf7274cb444c01d2bb123727c4bb0b9acabcb515dea"}, {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"},
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5a78cf2c43b13aa6b56003707c5203f28585944c277c1f3f109c7b041b16bd39"}, {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"},
{file = "coverage-6.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9229d074e097f21dfe0643d9d0140ee7433814b3f0fc3706b4abffd1e3038632"}, {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"},
{file = "coverage-6.4-cp310-cp310-win32.whl", hash = "sha256:fb45fe08e1abc64eb836d187b20a59172053999823f7f6ef4f18a819c44ba16f"}, {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"},
{file = "coverage-6.4-cp310-cp310-win_amd64.whl", hash = "sha256:3cfd07c5889ddb96a401449109a8b97a165be9d67077df6802f59708bfb07720"}, {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"},
{file = "coverage-6.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:03014a74023abaf5a591eeeaf1ac66a73d54eba178ff4cb1fa0c0a44aae70383"}, {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"},
{file = "coverage-6.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82f2cd69c71698152e943f4a5a6b83a3ab1db73b88f6e769fabc86074c3b08"}, {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"},
{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.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"},
{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.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-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c53ad261dfc8695062fc8811ac7c162bd6096a05a19f26097f411bdf5747aee7"}, {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"},
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:eef5292b60b6de753d6e7f2d128d5841c7915fb1e3321c3a1fe6acfe76c38052"}, {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"},
{file = "coverage-6.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:543e172ce4c0de533fa892034cce260467b213c0ea8e39da2f65f9a477425211"}, {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"},
{file = "coverage-6.4-cp37-cp37m-win32.whl", hash = "sha256:00c8544510f3c98476bbd58201ac2b150ffbcce46a8c3e4fb89ebf01998f806a"}, {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"},
{file = "coverage-6.4-cp37-cp37m-win_amd64.whl", hash = "sha256:b84ab65444dcc68d761e95d4d70f3cfd347ceca5a029f2ffec37d4f124f61311"}, {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"},
{file = "coverage-6.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d548edacbf16a8276af13063a2b0669d58bbcfca7c55a255f84aac2870786a61"}, {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"},
{file = "coverage-6.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:033ebec282793bd9eb988d0271c211e58442c31077976c19c442e24d827d356f"}, {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"},
{file = "coverage-6.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742fb8b43835078dd7496c3c25a1ec8d15351df49fb0037bffb4754291ef30ce"}, {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"},
{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.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"},
{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.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-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:62d382f7d77eeeaff14b30516b17bcbe80f645f5cf02bb755baac376591c653c"}, {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"},
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:016d7f5cf1c8c84f533a3c1f8f36126fbe00b2ec0ccca47cc5731c3723d327c6"}, {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"},
{file = "coverage-6.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:69432946f154c6add0e9ede03cc43b96e2ef2733110a77444823c053b1ff5166"}, {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"},
{file = "coverage-6.4-cp38-cp38-win32.whl", hash = "sha256:83bd142cdec5e4a5c4ca1d4ff6fa807d28460f9db919f9f6a31babaaa8b88426"}, {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"},
{file = "coverage-6.4-cp38-cp38-win_amd64.whl", hash = "sha256:4002f9e8c1f286e986fe96ec58742b93484195defc01d5cc7809b8f7acb5ece3"}, {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"},
{file = "coverage-6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e4f52c272fdc82e7c65ff3f17a7179bc5f710ebc8ce8a5cadac81215e8326740"}, {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"},
{file = "coverage-6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5578efe4038be02d76c344007b13119b2b20acd009a88dde8adec2de4f630b5"}, {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"},
{file = "coverage-6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8099ea680201c2221f8468c372198ceba9338a5fec0e940111962b03b3f716a"}, {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"},
{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.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"},
{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.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-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8d2e80dd3438e93b19e1223a9850fa65425e77f2607a364b6fd134fcd52dc9df"}, {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"},
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:341e9c2008c481c5c72d0e0dbf64980a4b2238631a7f9780b0fe2e95755fb018"}, {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"},
{file = "coverage-6.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:21e6686a95025927775ac501e74f5940cdf6fe052292f3a3f7349b0abae6d00f"}, {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"},
{file = "coverage-6.4-cp39-cp39-win32.whl", hash = "sha256:968ed5407f9460bd5a591cefd1388cc00a8f5099de9e76234655ae48cfdbe2c3"}, {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"},
{file = "coverage-6.4-cp39-cp39-win_amd64.whl", hash = "sha256:e35217031e4b534b09f9b9a5841b9344a30a6357627761d4218818b865d45055"}, {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"},
{file = "coverage-6.4-pp36.pp37.pp38-none-any.whl", hash = "sha256:e637ae0b7b481905358624ef2e81d7fb0b1af55f5ff99f9ba05442a444b11e45"}, {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"},
{file = "coverage-6.4.tar.gz", hash = "sha256:727dafd7f67a6e1cad808dc884bd9c5a2f6ef1f8f6d2f22b37b96cb0080d4f49"}, {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"},
] ]
croniter = [ croniter = [
{file = "croniter-1.3.5-py2.py3-none-any.whl", hash = "sha256:4f72faca42c00beb6e30907f1315145f43dfbe5ec0ad4ada24b4c0d57b86a33a"}, {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.tar.gz", hash = "sha256:b794b4e64f482de77f376451f7cd8b3c8448eb68e5a24c51b9190625a08b0b30"},
{file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"}, {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 = [ fakeredis = [
{file = "fakeredis-1.8-py3-none-any.whl", hash = "sha256:65dcd78c0cd29d17daccce9f58698f6ab61ad7a404eab373fcad2b76fe8db03d"}, {file = "fakeredis-1.8-py3-none-any.whl", hash = "sha256:65dcd78c0cd29d17daccce9f58698f6ab61ad7a404eab373fcad2b76fe8db03d"},
{file = "fakeredis-1.8.tar.gz", hash = "sha256:cbf8d74ae06672d40b2fa88b9ee4f1d6efd56b06b2e7f0be2c639647f00643f1"}, {file = "fakeredis-1.8.tar.gz", hash = "sha256:cbf8d74ae06672d40b2fa88b9ee4f1d6efd56b06b2e7f0be2c639647f00643f1"},

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import re
from xml.sax import saxutils from xml.sax import saxutils
from django import http from django import http
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import Feed, add_domain from django.contrib.syndication.views import Feed, add_domain
from django.utils import feedgenerator 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"), 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( feed.add_item(
title=video.title, title=video.title,
link=video.get_absolute_url(), link=video.get_absolute_url(),
@ -187,6 +190,6 @@ class UcastFeed(Feed):
request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3" request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3"
), ),
length=str(item.download_size), length=str(item.download_size),
mime_type="audio/mpg", mime_type="audio/mpeg",
) )
return [enc] return [enc]

View file

@ -3,3 +3,7 @@ from django import forms
class AddChannelForm(forms.Form): class AddChannelForm(forms.Form):
channel_str = forms.CharField(label="Channel-ID / URL") 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.models
import django.contrib.auth.validators import django.contrib.auth.validators
@ -28,15 +28,19 @@ class Migration(migrations.Migration):
verbose_name="ID", 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)), ("name", models.CharField(max_length=100)),
("slug", models.CharField(max_length=100)), ("slug", models.CharField(db_index=True, max_length=100)),
("description", models.TextField()), ("description", models.TextField()),
("subscribers", models.CharField(max_length=20, null=True)), ("subscribers", models.CharField(max_length=20, null=True)),
("active", models.BooleanField(default=True)), ("active", models.BooleanField(default=True)),
("skip_livestreams", models.BooleanField(default=True)), ("skip_livestreams", models.BooleanField(default=True)),
("skip_shorts", models.BooleanField(default=True)), ("skip_shorts", models.BooleanField(default=True)),
("avatar_url", models.CharField(max_length=250, null=True)), ("avatar_url", models.CharField(max_length=250, null=True)),
(
"last_update",
models.DateTimeField(default=django.utils.timezone.now),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
@ -51,9 +55,9 @@ class Migration(migrations.Migration):
verbose_name="ID", 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)), ("title", models.CharField(max_length=200)),
("slug", models.CharField(max_length=209)), ("slug", models.CharField(db_index=True, max_length=209)),
("published", models.DateTimeField()), ("published", models.DateTimeField()),
("downloaded", models.DateTimeField(null=True)), ("downloaded", models.DateTimeField(null=True)),
("description", models.TextField()), ("description", models.TextField()),
@ -61,6 +65,7 @@ class Migration(migrations.Migration):
("is_livestream", models.BooleanField(default=False)), ("is_livestream", models.BooleanField(default=False)),
("is_short", models.BooleanField(default=False)), ("is_short", models.BooleanField(default=False)),
("download_size", models.IntegerField(null=True)), ("download_size", models.IntegerField(null=True)),
("is_deleted", models.BooleanField(default=False)),
( (
"channel", "channel",
models.ForeignKey( models.ForeignKey(

View file

@ -4,6 +4,7 @@ import datetime
from Cryptodome import Random from Cryptodome import Random
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils import timezone
from ucast.service import util from ucast.service import util
@ -31,15 +32,16 @@ def _get_unique_slug(
class Channel(models.Model): 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) name = models.CharField(max_length=100)
slug = models.CharField(max_length=100) slug = models.CharField(max_length=100, db_index=True)
description = models.TextField() description = models.TextField()
subscribers = models.CharField(max_length=20, null=True) subscribers = models.CharField(max_length=20, null=True)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
skip_livestreams = models.BooleanField(default=True) skip_livestreams = models.BooleanField(default=True)
skip_shorts = models.BooleanField(default=True) skip_shorts = models.BooleanField(default=True)
avatar_url = models.CharField(max_length=250, null=True) avatar_url = models.CharField(max_length=250, null=True)
last_update = models.DateTimeField(default=timezone.now)
@classmethod @classmethod
def get_new_slug(cls, name: str) -> str: def get_new_slug(cls, name: str) -> str:
@ -73,9 +75,9 @@ class Channel(models.Model):
class Video(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) 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) channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
published = models.DateTimeField() published = models.DateTimeField()
downloaded = models.DateTimeField(null=True) downloaded = models.DateTimeField(null=True)
@ -84,6 +86,7 @@ class Video(models.Model):
is_livestream = models.BooleanField(default=False) is_livestream = models.BooleanField(default=False)
is_short = models.BooleanField(default=False) is_short = models.BooleanField(default=False)
download_size = models.IntegerField(null=True) download_size = models.IntegerField(null=True)
is_deleted = models.BooleanField(default=False)
@classmethod @classmethod
def get_new_slug(cls, title: str, date: datetime.date, channel_id: str) -> str: 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 fonts.ttf import SourceSansPro
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont 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_BLUR = "blur"
COVER_STYLE_GRADIENT = "gradient" COVER_STYLE_GRADIENT = "gradient"
@ -105,8 +105,9 @@ def _draw_text_box(
x_tl, y_tl, x_br, y_br = box x_tl, y_tl, x_br, y_br = box
height = y_br - y_tl height = y_br - y_tl
width = x_br - x_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 y_start = y_tl
if vertical_center: if vertical_center:

View file

@ -1,6 +1,8 @@
import datetime import datetime
import io import io
import json import json
import os
import re
from pathlib import Path from pathlib import Path
from typing import Any, Union from typing import Any, Union
from urllib import parse from urllib import parse
@ -13,6 +15,23 @@ from PIL import Image
AVATAR_SM_WIDTH = 100 AVATAR_SM_WIDTH = 100
THUMBNAIL_SM_WIDTH = 360 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): def download_file(url: str, download_path: Path):
r = requests.get(url, allow_redirects=True) r = requests.get(url, allow_redirects=True)
@ -163,3 +182,13 @@ def add_key_to_url(url: str, key: str):
query["key"] = key query["key"] = key
url_parts[4] = _urlencode(query) url_parts[4] = _urlencode(query)
return parse.urlunparse(url_parts) 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( def download_audio(
video_id: str, download_path: Path, sponsorblock=False video_id: str, download_path: Path, sponsorblock=False
) -> VideoDetails: ) -> 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) tmp_dld_file = download_path.with_suffix(".dld" + download_path.suffix)
ydl_params = { ydl_params = {
@ -160,7 +168,8 @@ def download_audio(
# extract_info downloads the video and returns its metadata # extract_info downloads the video and returns its metadata
info = ydl.extract_info(video_id) 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) 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 import os
from django.db.models import ObjectDoesNotExist
from django.utils import timezone from django.utils import timezone
from ucast import queue from ucast import queue
from ucast.models import Channel, Video from ucast.models import Channel, Video
from ucast.service import cover, storage, util, videoutil, youtube from ucast.service import controller, 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
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel): def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
if Video.objects.filter(video_id=vid.id).exists(): # Create video object if it does not exist
return 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 slug = Video.get_new_slug(
if details.is_currently_live: details.title, details.published.date(), channel.channel_id
return )
slug = Video.get_new_slug( video = Video(
details.title, details.published.date(), channel.channel_id 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( if (
video_id=details.id, video.downloaded is None
title=details.title, and video.is_deleted is False
slug=slug, and channel.should_download(video)
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):
queue.enqueue(download_video, video) queue.enqueue(download_video, video)
@ -89,6 +63,10 @@ def download_video(video: Video):
youtube.download_thumbnail(details, tn_path) youtube.download_thumbnail(details, tn_path)
util.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True)) util.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True))
cover_file = channel_folder.get_cover(video.slug) 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( cover.create_cover_file(
tn_path, tn_path,
channel_folder.file_avatar, channel_folder.file_avatar,
@ -109,25 +87,10 @@ def download_video(video: Video):
video.downloaded = timezone.now() video.downloaded = timezone.now()
video.download_size = os.path.getsize(audio_file) video.download_size = os.path.getsize(audio_file)
video.download_type, _ = mimetypes.guess_type(audio_file)
video.save() 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): def update_channel(channel: Channel):
"""Update a single channel from its RSS feed""" """Update a single channel from its RSS feed"""
videos = youtube.get_channel_videos_from_feed(channel.channel_id) videos = youtube.get_channel_videos_from_feed(channel.channel_id)
@ -135,6 +98,9 @@ def update_channel(channel: Channel):
for vid in videos: for vid in videos:
_load_scraped_video(vid, channel) _load_scraped_video(vid, channel)
channel.last_update = timezone.now()
channel.save()
def update_channels(): def update_channels():
""" """

View file

@ -6,8 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock title %}</title> <title>{% block title %}{% endblock title %}</title>
{% block css %} {% 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' %}"> <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 %} {% block extra_css %}{% endblock extra_css %}
{% endblock css %} {% endblock css %}
</head> </head>

View file

@ -6,11 +6,26 @@
<h1 class="title">Channels</h1> <h1 class="title">Channels</h1>
<div class="box"> <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 %} {% csrf_token %}
<div class="field has-addons"> <div class="field has-addons">
<div class="control is-flex-grow-1"> <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>
<div class="control"> <div class="control">
<button type="submit" class="button is-primary"> <button type="submit" class="button is-primary">
@ -24,13 +39,19 @@
{% for channel in channels %} {% for channel in channels %}
<div class="box is-flex"> <div class="box is-flex">
<div class="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"> <img class="channel-icon" src="/files/avatar/{{ channel.slug }}.webp?sm">
</a> </a>
</div> </div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1"> <div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="/channel/{{ channel.slug }}">{{ channel.name }}</a> <a class="subtitle" href="/channel/{{ channel.slug }}">{{ channel.name }}</a>
<div class="field has-addons"> <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"> <div class="control is-flex-grow-1">
<input class="input" type="text" <input class="input" type="text"
value="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}" value="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"

View file

@ -4,91 +4,81 @@
{% block content %} {% block content %}
<div class="level"> <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"> <div class="tags">
<span class="tag"><i <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 <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 <span class="tag"><i
class="fas fa-database"></i>&nbsp; {{ channel.download_size|filesizeformat }}</span> class="fas fa-database"></i>&nbsp; {{ channel.download_size|filesizeformat }}</span>
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i <a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
class="fa-brands fa-youtube"></i>&nbsp; {{ channel.channel_id }}</a> class="fa-brands fa-youtube"></i>&nbsp; {{ channel.channel_id }}</a>
</div> </div>
<div class="field has-addons"> <form method="post">
<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>
<div class="field has-addons"> <div class="field has-addons">
{% if video.downloaded %} <div class="control">
<div class="control"> <a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
<a class="button is-success" class="button">
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3" <i class="fas fa-rss"></i>
target="_blank"> </a>
<i class="fas fa-play"></i> </div>
</a> <div class="control">
</div> {% if channel.active %}
<div class="control"> <button type="submit" name="deactivate" class="button is-success">
<button class="button is-danger"> <i class="fas fa-power-off"></i>
<i class="fas fa-trash"></i>
</button> </button>
</div> {% else %}
{% else %} <button type="submit" name="activate" class="button is-danger">
<div class="control"> <i class="fas fa-power-off"></i>
<button class="button is-primary">
<i class="fas fa-download"></i>
</button> </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>
</div> {% csrf_token %}
{% endfor %} </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 %} {% 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)) 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 @pytest.mark.django_db
def test_update_channel( def test_update_channel(
download_dir, rq_queue, mock_get_video_details, mock_download_audio download_dir, rq_queue, mock_get_video_details, mock_download_audio

View file

@ -4,7 +4,7 @@ from ucast import views
urlpatterns = [ urlpatterns = [
path("", views.home), 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("feed/<str:channel>", views.podcast_feed),
path("files/audio/<str:channel>/<str:video>", views.audio), path("files/audio/<str:channel>/<str:video>", views.audio),
path("files/cover/<str:channel>/<str:video>", views.cover), 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.auth.decorators import login_required
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.contrib.syndication.views import add_domain from django.contrib.syndication.views import add_domain
from django.core.paginator import Paginator
from django.middleware.http import ConditionalGetMiddleware from django.middleware.http import ConditionalGetMiddleware
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import decorator_from_middleware from django.utils.decorators import decorator_from_middleware
from ucast import feed, forms, queue from ucast import feed, forms, queue
from ucast.models import Channel, User, Video from ucast.models import Channel, User, Video
from ucast.service import storage from ucast.service import controller, storage
from ucast.tasks import download from ucast.tasks import download
@ -23,12 +25,22 @@ def home(request: http.HttpRequest):
channels = Channel.objects.all() channels = Channel.objects.all()
site_url = add_domain(get_current_site(request).domain, "", request.is_secure()) site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
form = forms.AddChannelForm()
if request.method == "POST": if request.method == "POST":
form = forms.AddChannelForm(request.POST) form = forms.AddChannelForm(request.POST)
if form.is_valid(): if form.is_valid():
channel_str = form.cleaned_data["channel_str"] 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( return render(
request, request,
@ -36,7 +48,7 @@ def home(request: http.HttpRequest):
{ {
"channels": channels, "channels": channels,
"site_url": site_url, "site_url": site_url,
"add_channel_form": forms.AddChannelForm, "form": form,
}, },
) )
@ -44,14 +56,49 @@ def home(request: http.HttpRequest):
@login_required @login_required
def videos(request: http.HttpRequest, channel: str): def videos(request: http.HttpRequest, channel: str):
chan = Channel.objects.get(slug=channel) 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()) 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( return render(
request, request,
"ucast/videos.html", template_name,
{"videos": vids, "channel": chan, "site_url": site_url}, {
"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.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"bulma", "bulma",
"django_htmx",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -109,6 +110,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
] ]
ROOT_URLCONF = "ucast_project.urls" ROOT_URLCONF = "ucast_project.urls"
@ -218,6 +220,7 @@ REDIS_QUEUE_TIMEOUT = get_env("REDIS_QUEUE_TIMEOUT", 600)
REDIS_QUEUE_RESULT_TTL = 600 REDIS_QUEUE_RESULT_TTL = 600
YT_UPDATE_INTERVAL = get_env("YT_UPDATE_INTERVAL", 900) 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_FILES_ROOT = get_env("INTERNAL_FILES_ROOT", "internal_files")
INTERNAL_REDIRECT_HEADER = get_env("INTERNAL_REDIRECT_HEADER", "X-Accel-Redirect") INTERNAL_REDIRECT_HEADER = get_env("INTERNAL_REDIRECT_HEADER", "X-Accel-Redirect")