Compare commits
5 commits
936a412caf
...
16a509ee20
Author | SHA1 | Date | |
---|---|---|---|
16a509ee20 | |||
48cecbb621 | |||
55d7d5f3b2 | |||
630b29951a | |||
2bb670a5c6 |
46 changed files with 1439 additions and 211 deletions
|
@ -10,5 +10,5 @@ max_line_length = 88
|
||||||
[{Makefile,*.go}]
|
[{Makefile,*.go}]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
[*.{json,md,rst,ini,yml,yaml}]
|
[*.{json,md,rst,ini,yml,yaml,html,js,jsx,ts,tsx,vue}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
2
.env
2
.env
|
@ -1,2 +0,0 @@
|
||||||
UCAST_DEBUG=True
|
|
||||||
UCAST_WORKDIR=_run
|
|
3
.env.example
Normal file
3
.env.example
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
UCAST_DEBUG=True
|
||||||
|
UCAST_WORKDIR=_run
|
||||||
|
UCAST_ALLOWED_HOSTS=localhost,127.0.0.1
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,6 +15,7 @@ node_modules
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# Application data
|
# Application data
|
||||||
|
/.env
|
||||||
/_run*
|
/_run*
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
// 1. Import the initial variables
|
|
||||||
@import "../../node_modules/bulma/sass/utilities/initial-variables"
|
@import "../../node_modules/bulma/sass/utilities/initial-variables"
|
||||||
|
|
||||||
// 2. Set your own initial variables
|
|
||||||
|
|
||||||
// 3. Import the rest of Bulma
|
|
||||||
@import "../../node_modules/bulma/bulma"
|
@import "../../node_modules/bulma/bulma"
|
||||||
|
|
||||||
// 4. Import your stuff here
|
.channel-icon
|
||||||
|
max-height: 64px
|
||||||
|
|
||||||
|
.video-thumbnail
|
||||||
|
max-height: 128px
|
||||||
|
|
|
@ -12,3 +12,11 @@ services:
|
||||||
- "127.0.0.1:9181:9181"
|
- "127.0.0.1:9181:9181"
|
||||||
environment:
|
environment:
|
||||||
RQ_DASHBOARD_REDIS_URL: "redis://redis:6379"
|
RQ_DASHBOARD_REDIS_URL: "redis://redis:6379"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:1
|
||||||
|
network_mode: "host"
|
||||||
|
volumes:
|
||||||
|
- "./nginx:/etc/nginx/conf.d:ro"
|
||||||
|
- "../_run/static:/static:ro"
|
||||||
|
- "../_run/data:/files:ro"
|
||||||
|
|
26
deploy/nginx/ucast.conf
Normal file
26
deploy/nginx/ucast.conf
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
client_max_body_size 1M;
|
||||||
|
|
||||||
|
# serve media files
|
||||||
|
location /static/ {
|
||||||
|
alias /static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /internal_files/ {
|
||||||
|
internal;
|
||||||
|
alias /files/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# location /errors/ {
|
||||||
|
# alias /etc/nginx/conf.d/errorpages/;
|
||||||
|
# internal;
|
||||||
|
# }
|
||||||
|
}
|
34
notes/Feed.md
Normal file
34
notes/Feed.md
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
Django-Klasse: `django.utils.feedgenerator.Rss201rev2Feed`
|
||||||
|
|
||||||
|
### Channel-Attribute
|
||||||
|
|
||||||
|
| Tag | Beschreibung | Django-Attribut |
|
||||||
|
|--------------------------------------|-----------------------------------------|----------------------|
|
||||||
|
| `\<atom:link href="" rel="self">` | Feed-URL | `feed_url` |
|
||||||
|
| `\<title>` | Kanalname | `title` |
|
||||||
|
| `\<language>` | Sprache | `language` |
|
||||||
|
| `\<lastBuildDate>` | Datum der letzten Veränderung des Feeds | `latest_post_date()` |
|
||||||
|
| `\<description>` | Kanalbeschreibung | `description` |
|
||||||
|
| `\<link>` | Link zum Kanal | `link` |
|
||||||
|
| `\<copyright>` | Autor | `feed_copyright` |
|
||||||
|
| `\<image><url><title><link></image>` | Cover-URL / Kanalname / Link | - |
|
||||||
|
| `\<itunes:image href="">` | Cover-URL | - |
|
||||||
|
| `\<itunes:author>` | Autor | - |
|
||||||
|
| `\<itunes:summary>` | Kanalbeschreibung | - |
|
||||||
|
|
||||||
|
|
||||||
|
### Item-Attribute
|
||||||
|
|
||||||
|
| Tag | Beschreibung | Django-Attribut |
|
||||||
|
|--------------------------------------------------|------------------------|-----------------|
|
||||||
|
| `\<title>` | Titel | `title` |
|
||||||
|
| `\<itunes:title>` | Titel | - |
|
||||||
|
| `\<description>` | Beschreibung | `description` |
|
||||||
|
| `\<pubDate>` | Veröffentlichungsdatum | `pubdate` |
|
||||||
|
| `\<link>` | Link | `link` |
|
||||||
|
| `\<guid>` | Eindeutige ID/ | `unique_id` |
|
||||||
|
| `\<itunes:summary>` | Bechreibung | - |
|
||||||
|
| `\<itunes:author>` | Autor | - |
|
||||||
|
| `\<enclosure url="" type="audio/mpeg" length=1>` | Audiodatei | `enclosures ` |
|
||||||
|
| `\<itunes:duration>00:40:35</itunes:duration>` | Dauer | - |
|
||||||
|
| `\<itunes:image href="">` | Cover-URL | - |
|
47
poetry.lock
generated
47
poetry.lock
generated
|
@ -203,21 +203,20 @@ django = ">=2.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fakeredis"
|
name = "fakeredis"
|
||||||
version = "1.7.5"
|
version = "1.8"
|
||||||
description = "Fake implementation of redis API for testing purposes."
|
description = "Fake implementation of redis API for testing purposes."
|
||||||
category = "dev"
|
category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7,<4.0"
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
packaging = "*"
|
|
||||||
redis = "<=4.3.1"
|
redis = "<=4.3.1"
|
||||||
six = ">=1.12"
|
six = ">=1.16.0,<2.0.0"
|
||||||
sortedcontainers = "*"
|
sortedcontainers = ">=2.4.0,<3.0.0"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
aioredis = ["aioredis"]
|
aioredis = ["aioredis (>=2.0.1,<3.0.0)"]
|
||||||
lua = ["lupa"]
|
lua = ["lupa (>=1.13,<2.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "feedparser"
|
name = "feedparser"
|
||||||
|
@ -307,19 +306,6 @@ category = "dev"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mock"
|
|
||||||
version = "4.0.3"
|
|
||||||
description = "Rolling backport of unittest.mock for all Pythons"
|
|
||||||
category = "dev"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
build = ["twine", "wheel", "blurb"]
|
|
||||||
docs = ["sphinx"]
|
|
||||||
test = ["pytest (<5.4)", "pytest-cov"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mutagen"
|
name = "mutagen"
|
||||||
version = "1.45.1"
|
version = "1.45.1"
|
||||||
|
@ -594,14 +580,6 @@ urllib3 = ">=1.21.1,<1.27"
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
|
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
|
||||||
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
|
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rfeed"
|
|
||||||
version = "1.1.1"
|
|
||||||
description = "Python RSS 2.0 Generator"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rq"
|
name = "rq"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
|
@ -765,7 +743,7 @@ websockets = "*"
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "ad3a5ecd6fc1152dfdfda51ed1e401ec11a048661a04f42985c15bc28e8eda9f"
|
content-hash = "1d1799636eadf391bd9545eb3d8750a1e882cdb9b84d47395284d90a3cfeb609"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
asgiref = [
|
asgiref = [
|
||||||
|
@ -1020,8 +998,8 @@ django-bulma = [
|
||||||
{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"},
|
||||||
]
|
]
|
||||||
fakeredis = [
|
fakeredis = [
|
||||||
{file = "fakeredis-1.7.5-py3-none-any.whl", hash = "sha256:c4ca2be686e7e7637756ccc7dcad8472a5e4866b065431107d7a4b7a250d4e6f"},
|
{file = "fakeredis-1.8-py3-none-any.whl", hash = "sha256:65dcd78c0cd29d17daccce9f58698f6ab61ad7a404eab373fcad2b76fe8db03d"},
|
||||||
{file = "fakeredis-1.7.5.tar.gz", hash = "sha256:49375c630981dd4045d9a92e2709fcd4476c91f927e0228493eefa625e705133"},
|
{file = "fakeredis-1.8.tar.gz", hash = "sha256:cbf8d74ae06672d40b2fa88b9ee4f1d6efd56b06b2e7f0be2c639647f00643f1"},
|
||||||
]
|
]
|
||||||
feedparser = [
|
feedparser = [
|
||||||
{file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"},
|
{file = "feedparser-6.0.10-py3-none-any.whl", hash = "sha256:79c257d526d13b944e965f6095700587f27388e50ea16fd245babe4dfae7024f"},
|
||||||
|
@ -1061,10 +1039,6 @@ invoke = [
|
||||||
{file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"},
|
{file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"},
|
||||||
{file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"},
|
{file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"},
|
||||||
]
|
]
|
||||||
mock = [
|
|
||||||
{file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"},
|
|
||||||
{file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"},
|
|
||||||
]
|
|
||||||
mutagen = [
|
mutagen = [
|
||||||
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
|
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
|
||||||
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
|
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
|
||||||
|
@ -1261,9 +1235,6 @@ requests = [
|
||||||
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
|
||||||
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
|
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
|
||||||
]
|
]
|
||||||
rfeed = [
|
|
||||||
{file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"},
|
|
||||||
]
|
|
||||||
rq = [
|
rq = [
|
||||||
{file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"},
|
{file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"},
|
||||||
{file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"},
|
{file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"},
|
||||||
|
|
|
@ -13,7 +13,6 @@ python = "^3.10"
|
||||||
Django = "^4.0.4"
|
Django = "^4.0.4"
|
||||||
yt-dlp = "^2022.3.8"
|
yt-dlp = "^2022.3.8"
|
||||||
requests = "^2.27.1"
|
requests = "^2.27.1"
|
||||||
rfeed = "^1.1.1"
|
|
||||||
feedparser = "^6.0.8"
|
feedparser = "^6.0.8"
|
||||||
Pillow = "^9.1.0"
|
Pillow = "^9.1.0"
|
||||||
colorthief = "^0.2.1"
|
colorthief = "^0.2.1"
|
||||||
|
@ -28,6 +27,7 @@ python-slugify = "^6.1.2"
|
||||||
mutagen = "^1.45.1"
|
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"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.1.1"
|
pytest = "^7.1.1"
|
||||||
|
|
16
tasks.py
16
tasks.py
|
@ -130,3 +130,19 @@ def worker(c, n=2):
|
||||||
|
|
||||||
m.loop()
|
m.loop()
|
||||||
sys.exit(m.returncode)
|
sys.exit(m.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def optimize_svg(c):
|
||||||
|
out_dir = Path("ucast/static/ucast")
|
||||||
|
|
||||||
|
for icon in (Path("assets/icons/logo.svg"), Path("assets/icons/logo_dark.svg")):
|
||||||
|
c.run(
|
||||||
|
f"scour --indent=none --no-line-breaks --enable-comment-stripping {icon} {out_dir / icon.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def build_sass(c):
|
||||||
|
c.run("npm run build")
|
||||||
|
collectstatic(c)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from ucast.models import Channel, Video
|
from ucast.models import Channel, User, Video
|
||||||
|
|
||||||
|
|
||||||
class ChannelAdmin(admin.ModelAdmin):
|
class ChannelAdmin(admin.ModelAdmin):
|
||||||
|
@ -14,3 +14,4 @@ class VideoAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
admin.site.register(Channel, ChannelAdmin)
|
admin.site.register(Channel, ChannelAdmin)
|
||||||
admin.site.register(Video, VideoAdmin)
|
admin.site.register(Video, VideoAdmin)
|
||||||
|
admin.site.register(User)
|
||||||
|
|
192
ucast/feed.py
Normal file
192
ucast/feed.py
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
import re
|
||||||
|
from xml.sax import saxutils
|
||||||
|
|
||||||
|
from django import http
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.contrib.syndication.views import Feed, add_domain
|
||||||
|
from django.utils import feedgenerator
|
||||||
|
from django.utils.feedgenerator import Rss201rev2Feed, rfc2822_date
|
||||||
|
from django.utils.xmlutils import SimplerXMLGenerator
|
||||||
|
|
||||||
|
from ucast.models import Channel, Video
|
||||||
|
from ucast.service import util
|
||||||
|
|
||||||
|
URL_REGEX = r"""http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"""
|
||||||
|
|
||||||
|
|
||||||
|
class PodcastFeedType(Rss201rev2Feed):
|
||||||
|
content_type = "application/xml; charset=utf-8"
|
||||||
|
|
||||||
|
def rss_attributes(self):
|
||||||
|
attrs = super().rss_attributes()
|
||||||
|
attrs["xmlns:itunes"] = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _xml_escape(text: str) -> str:
|
||||||
|
text = saxutils.escape(text)
|
||||||
|
text = re.sub(URL_REGEX, lambda m: f'<a href="{m[0]}">{m[0]}</a>', text)
|
||||||
|
text = text.replace("\n", "<br>")
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_text_element(handler: SimplerXMLGenerator, text: str):
|
||||||
|
handler.startElement("description", {})
|
||||||
|
handler.ignorableWhitespace(f"<![CDATA[{PodcastFeedType._xml_escape(text)}]]>")
|
||||||
|
handler.endElement("description")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_secs(secs: int) -> str:
|
||||||
|
mm, ss = divmod(secs, 60)
|
||||||
|
hh, mm = divmod(mm, 60)
|
||||||
|
s = "%02d:%02d:%02d" % (hh, mm, ss)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def add_root_elements(self, handler: SimplerXMLGenerator):
|
||||||
|
handler.addQuickElement("title", self.feed["title"])
|
||||||
|
handler.addQuickElement("link", self.feed["link"])
|
||||||
|
self._add_text_element(handler, self.feed["description"])
|
||||||
|
if self.feed["feed_url"] is not None:
|
||||||
|
handler.addQuickElement(
|
||||||
|
"atom:link", None, {"rel": "self", "href": self.feed["feed_url"]}
|
||||||
|
)
|
||||||
|
if self.feed["language"] is not None:
|
||||||
|
handler.addQuickElement("language", self.feed["language"])
|
||||||
|
for cat in self.feed["categories"]:
|
||||||
|
handler.addQuickElement("category", cat)
|
||||||
|
if self.feed["feed_copyright"] is not None:
|
||||||
|
handler.addQuickElement("copyright", self.feed["feed_copyright"])
|
||||||
|
handler.addQuickElement("lastBuildDate", rfc2822_date(self.latest_post_date()))
|
||||||
|
if self.feed["ttl"] is not None:
|
||||||
|
handler.addQuickElement("ttl", self.feed["ttl"])
|
||||||
|
|
||||||
|
if self.feed.get("image_url") is not None:
|
||||||
|
handler.startElement("image", {})
|
||||||
|
handler.addQuickElement("url", self.feed["image_url"])
|
||||||
|
handler.addQuickElement("title", self.feed["title"])
|
||||||
|
handler.addQuickElement("link", self.feed["link"])
|
||||||
|
handler.endElement("image")
|
||||||
|
|
||||||
|
handler.addQuickElement(
|
||||||
|
"itunes:image", None, {"href": self.feed["image_url"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_item_elements(self, handler: SimplerXMLGenerator, item):
|
||||||
|
handler.addQuickElement("title", item["title"])
|
||||||
|
handler.addQuickElement("link", item["link"])
|
||||||
|
|
||||||
|
if item["description"] is not None:
|
||||||
|
self._add_text_element(handler, item["description"])
|
||||||
|
|
||||||
|
# Author information.
|
||||||
|
if item["author_name"] and item["author_email"]:
|
||||||
|
handler.addQuickElement(
|
||||||
|
"author", "%s (%s)" % (item["author_email"], item["author_name"])
|
||||||
|
)
|
||||||
|
elif item["author_email"]:
|
||||||
|
handler.addQuickElement("author", item["author_email"])
|
||||||
|
elif item["author_name"]:
|
||||||
|
handler.addQuickElement(
|
||||||
|
"dc:creator",
|
||||||
|
item["author_name"],
|
||||||
|
{"xmlns:dc": "http://purl.org/dc/elements/1.1/"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if item["pubdate"] is not None:
|
||||||
|
handler.addQuickElement("pubDate", rfc2822_date(item["pubdate"]))
|
||||||
|
if item["comments"] is not None:
|
||||||
|
handler.addQuickElement("comments", item["comments"])
|
||||||
|
if item["unique_id"] is not None:
|
||||||
|
guid_attrs = {}
|
||||||
|
if isinstance(item.get("unique_id_is_permalink"), bool):
|
||||||
|
guid_attrs["isPermaLink"] = str(item["unique_id_is_permalink"]).lower()
|
||||||
|
handler.addQuickElement("guid", item["unique_id"], guid_attrs)
|
||||||
|
if item["ttl"] is not None:
|
||||||
|
handler.addQuickElement("ttl", item["ttl"])
|
||||||
|
|
||||||
|
# Enclosure.
|
||||||
|
if item["enclosures"]:
|
||||||
|
enclosures = list(item["enclosures"])
|
||||||
|
if len(enclosures) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
"RSS feed items may only have one enclosure, see "
|
||||||
|
"http://www.rssboard.org/rss-profile#element-channel-item-enclosure"
|
||||||
|
)
|
||||||
|
enclosure = enclosures[0]
|
||||||
|
handler.addQuickElement(
|
||||||
|
"enclosure",
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
"url": enclosure.url,
|
||||||
|
"length": enclosure.length,
|
||||||
|
"type": enclosure.mime_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Categories.
|
||||||
|
for cat in item["categories"]:
|
||||||
|
handler.addQuickElement("category", cat)
|
||||||
|
|
||||||
|
# Cover image
|
||||||
|
if item.get("image_url"):
|
||||||
|
handler.addQuickElement("itunes:image", None, {"href": item["image_url"]})
|
||||||
|
|
||||||
|
# Duration
|
||||||
|
if item.get("duration"):
|
||||||
|
handler.addQuickElement(
|
||||||
|
"itunes:duration", self._format_secs(item["duration"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UcastFeed(Feed):
|
||||||
|
feed_type = PodcastFeedType
|
||||||
|
|
||||||
|
def get_object(self, request, *args, **kwargs):
|
||||||
|
channel_slug = kwargs["channel"]
|
||||||
|
return Channel.objects.get(slug=channel_slug)
|
||||||
|
|
||||||
|
def get_feed(self, channel: Channel, request: http.HttpRequest):
|
||||||
|
feed = self.feed_type(
|
||||||
|
title=channel.name,
|
||||||
|
link=channel.get_absolute_url(),
|
||||||
|
description=channel.description,
|
||||||
|
language=self.language,
|
||||||
|
feed_url=self.full_link_url(request, f"/feed/{channel.slug}"),
|
||||||
|
image_url=self.full_link_url(request, f"/files/avatar/{channel.slug}.jpg"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for video in channel.video_set.order_by("-published"):
|
||||||
|
feed.add_item(
|
||||||
|
title=video.title,
|
||||||
|
link=video.get_absolute_url(),
|
||||||
|
description=video.description,
|
||||||
|
unique_id=video.get_absolute_url(),
|
||||||
|
unique_id_is_permalink=True,
|
||||||
|
enclosures=self.item_enclosures_domain(video, request),
|
||||||
|
pubdate=video.published,
|
||||||
|
updateddate=video.downloaded,
|
||||||
|
image_url=self.full_link_url(
|
||||||
|
request, f"/files/cover/{channel.slug}/{video.slug}.png"
|
||||||
|
),
|
||||||
|
duration=video.duration,
|
||||||
|
)
|
||||||
|
return feed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def full_link_url(request: http.HttpRequest, page_url: str) -> str:
|
||||||
|
anon_url = add_domain(
|
||||||
|
get_current_site(request).domain,
|
||||||
|
page_url,
|
||||||
|
request.is_secure(),
|
||||||
|
)
|
||||||
|
return util.add_key_to_url(anon_url, request.user.get_feed_key())
|
||||||
|
|
||||||
|
def item_enclosures_domain(self, item: Video, request: http.HttpRequest):
|
||||||
|
enc = feedgenerator.Enclosure(
|
||||||
|
url=self.full_link_url(
|
||||||
|
request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3"
|
||||||
|
),
|
||||||
|
length=str(item.download_size),
|
||||||
|
mime_type="audio/mpg",
|
||||||
|
)
|
||||||
|
return [enc]
|
5
ucast/forms.py
Normal file
5
ucast/forms.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
|
||||||
|
class AddChannelForm(forms.Form):
|
||||||
|
channel_str = forms.CharField(label="Channel-ID / URL")
|
|
@ -1,6 +1,9 @@
|
||||||
# Generated by Django 4.0.4 on 2022-05-05 00:02
|
# Generated by Django 4.0.4 on 2022-05-29 21:01
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,7 +11,9 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
|
@ -16,15 +21,21 @@ class Migration(migrations.Migration):
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
"id",
|
||||||
models.CharField(max_length=30, primary_key=True, serialize=False),
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
("channel_id", models.CharField(max_length=30)),
|
||||||
("name", models.CharField(max_length=100)),
|
("name", models.CharField(max_length=100)),
|
||||||
("slug", models.CharField(max_length=100)),
|
("slug", models.CharField(max_length=100)),
|
||||||
("description", models.TextField()),
|
("description", models.TextField()),
|
||||||
|
("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)),
|
||||||
("keep_videos", models.IntegerField(default=None, null=True)),
|
|
||||||
("avatar_url", models.CharField(max_length=250, null=True)),
|
("avatar_url", models.CharField(max_length=250, null=True)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -33,16 +44,16 @@ class Migration(migrations.Migration):
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
"id",
|
||||||
models.CharField(max_length=30, primary_key=True, serialize=False),
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
("video_id", models.CharField(max_length=30)),
|
||||||
("title", models.CharField(max_length=200)),
|
("title", models.CharField(max_length=200)),
|
||||||
("slug", models.CharField(max_length=209)),
|
("slug", models.CharField(max_length=209)),
|
||||||
(
|
|
||||||
"channel",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("published", models.DateTimeField()),
|
("published", models.DateTimeField()),
|
||||||
("downloaded", models.DateTimeField(null=True)),
|
("downloaded", models.DateTimeField(null=True)),
|
||||||
("description", models.TextField()),
|
("description", models.TextField()),
|
||||||
|
@ -50,6 +61,127 @@ 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)),
|
||||||
|
(
|
||||||
|
"channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="User",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
|
(
|
||||||
|
"last_login",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True, null=True, verbose_name="last login"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_superuser",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||||
|
verbose_name="superuser status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
models.CharField(
|
||||||
|
error_messages={
|
||||||
|
"unique": "A user with that username already exists."
|
||||||
|
},
|
||||||
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
|
max_length=150,
|
||||||
|
unique=True,
|
||||||
|
validators=[
|
||||||
|
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||||
|
],
|
||||||
|
verbose_name="username",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"first_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_name",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="last name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
blank=True, max_length=254, verbose_name="email address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_staff",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Designates whether the user can log into this admin site.",
|
||||||
|
verbose_name="staff status",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||||
|
verbose_name="active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"date_joined",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=django.utils.timezone.now, verbose_name="date joined"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("feed_key", models.CharField(default=None, max_length=50, null=True)),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_name="user_set",
|
||||||
|
related_query_name="user",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="user permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "user",
|
||||||
|
"verbose_name_plural": "users",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from Cryptodome import Random
|
||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from ucast.service import util
|
from ucast.service import util
|
||||||
|
@ -28,14 +31,14 @@ def _get_unique_slug(
|
||||||
|
|
||||||
|
|
||||||
class Channel(models.Model):
|
class Channel(models.Model):
|
||||||
id = models.CharField(max_length=30, primary_key=True)
|
channel_id = models.CharField(max_length=30)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.CharField(max_length=100)
|
slug = models.CharField(max_length=100)
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
|
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)
|
||||||
keep_videos = models.IntegerField(null=True, default=None)
|
|
||||||
avatar_url = models.CharField(max_length=250, null=True)
|
avatar_url = models.CharField(max_length=250, null=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -43,17 +46,34 @@ class Channel(models.Model):
|
||||||
return _get_unique_slug(name, cls.objects, "channel")
|
return _get_unique_slug(name, cls.objects, "channel")
|
||||||
|
|
||||||
def get_full_description(self) -> str:
|
def get_full_description(self) -> str:
|
||||||
desc = f"https://www.youtube.com/channel/{self.id}"
|
desc = f"https://www.youtube.com/channel/{self.channel_id}"
|
||||||
if self.description:
|
if self.description:
|
||||||
desc = f"{self.description}\n\n{desc}"
|
desc = f"{self.description}\n\n{desc}"
|
||||||
return desc
|
return desc
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return "https://www.youtube.com/channel/" + self.channel_id
|
||||||
|
|
||||||
|
def should_download(self, video: "Video") -> bool:
|
||||||
|
if self.skip_livestreams and video.is_livestream:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.skip_shorts and video.is_short:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_size(self) -> int:
|
||||||
|
return self.video_set.aggregate(models.Sum("download_size")).get(
|
||||||
|
"download_size__sum"
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Video(models.Model):
|
class Video(models.Model):
|
||||||
id = models.CharField(max_length=30, primary_key=True)
|
video_id = models.CharField(max_length=30)
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200)
|
||||||
slug = models.CharField(max_length=209)
|
slug = models.CharField(max_length=209)
|
||||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||||
|
@ -70,14 +90,37 @@ class Video(models.Model):
|
||||||
title_w_date = f"{date.strftime('%Y%m%d')}_{title}"
|
title_w_date = f"{date.strftime('%Y%m%d')}_{title}"
|
||||||
|
|
||||||
return _get_unique_slug(
|
return _get_unique_slug(
|
||||||
title_w_date, cls.objects.filter(channel_id=channel_id), "video"
|
title_w_date, cls.objects.filter(channel__channel_id=channel_id), "video"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_full_description(self) -> str:
|
def get_full_description(self) -> str:
|
||||||
desc = f"https://youtu.be/{self.id}"
|
desc = f"https://youtu.be/{self.video_id}"
|
||||||
if self.description:
|
if self.description:
|
||||||
desc = f"{self.description}\n\n{desc}"
|
desc = f"{self.description}\n\n{desc}"
|
||||||
return desc
|
return desc
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return f"https://www.youtube.com/watch?v={self.video_id}"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class User(AbstractUser):
|
||||||
|
feed_key = models.CharField(max_length=50, null=True, default=None)
|
||||||
|
|
||||||
|
def generate_feed_key(self):
|
||||||
|
for _ in range(0, User.objects.count()):
|
||||||
|
key = base64.urlsafe_b64encode(Random.get_random_bytes(18)).decode()
|
||||||
|
|
||||||
|
if not User.objects.filter(feed_key=key).exists():
|
||||||
|
self.feed_key = key
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
raise Exception("unique feed key could not be found")
|
||||||
|
|
||||||
|
def get_feed_key(self) -> str:
|
||||||
|
if self.feed_key is None:
|
||||||
|
self.generate_feed_key()
|
||||||
|
return self.feed_key
|
||||||
|
|
|
@ -52,6 +52,12 @@ class Storage:
|
||||||
self.dir_data = settings.DOWNLOAD_ROOT
|
self.dir_data = settings.DOWNLOAD_ROOT
|
||||||
|
|
||||||
def get_channel_folder(self, channel_slug: str) -> ChannelFolder:
|
def get_channel_folder(self, channel_slug: str) -> ChannelFolder:
|
||||||
|
cf = ChannelFolder(self.dir_data / channel_slug)
|
||||||
|
if not cf.does_exist():
|
||||||
|
raise FileNotFoundError
|
||||||
|
return cf
|
||||||
|
|
||||||
|
def get_or_create_channel_folder(self, channel_slug: str) -> ChannelFolder:
|
||||||
cf = ChannelFolder(self.dir_data / channel_slug)
|
cf = ChannelFolder(self.dir_data / channel_slug)
|
||||||
if not cf.does_exist():
|
if not cf.does_exist():
|
||||||
cf.create()
|
cf.create()
|
||||||
|
|
|
@ -3,6 +3,7 @@ import io
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
from urllib import parse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import slugify
|
import slugify
|
||||||
|
@ -108,3 +109,57 @@ def to_json(o, pretty=False) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
o, default=serializer, indent=2 if pretty else None, ensure_ascii=False
|
o, default=serializer, indent=2 if pretty else None, ensure_ascii=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _urlencode(query, safe="", encoding=None, errors=None, quote_via=parse.quote_plus):
|
||||||
|
"""
|
||||||
|
Same as the urllib.parse.urlencode function, but does not add an
|
||||||
|
equals sign to no-value flags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(query, "items"):
|
||||||
|
query = query.items()
|
||||||
|
else:
|
||||||
|
# It's a bother at times that strings and string-like objects are
|
||||||
|
# sequences.
|
||||||
|
try:
|
||||||
|
# non-sequence items should not work with len()
|
||||||
|
# non-empty strings will fail this
|
||||||
|
if len(query) and not isinstance(query[0], tuple):
|
||||||
|
raise TypeError
|
||||||
|
# Zero-length sequences of all types will get here and succeed,
|
||||||
|
# but that's a minor nit. Since the original implementation
|
||||||
|
# allowed empty dicts that type of behavior probably should be
|
||||||
|
# preserved for consistency
|
||||||
|
except TypeError:
|
||||||
|
raise TypeError("not a valid non-string sequence " "or mapping object")
|
||||||
|
|
||||||
|
lst = []
|
||||||
|
|
||||||
|
for k, v in query:
|
||||||
|
if isinstance(k, bytes):
|
||||||
|
k = quote_via(k, safe)
|
||||||
|
else:
|
||||||
|
k = quote_via(str(k), safe, encoding, errors)
|
||||||
|
|
||||||
|
if isinstance(v, bytes):
|
||||||
|
v = quote_via(v, safe)
|
||||||
|
else:
|
||||||
|
v = quote_via(str(v), safe, encoding, errors)
|
||||||
|
|
||||||
|
if v:
|
||||||
|
lst.append(k + "=" + v)
|
||||||
|
else:
|
||||||
|
lst.append(k)
|
||||||
|
|
||||||
|
return "&".join(lst)
|
||||||
|
|
||||||
|
|
||||||
|
def add_key_to_url(url: str, key: str):
|
||||||
|
if not key:
|
||||||
|
return url
|
||||||
|
url_parts = list(parse.urlparse(url))
|
||||||
|
query = dict(parse.parse_qsl(url_parts[4], keep_blank_values=True))
|
||||||
|
query["key"] = key
|
||||||
|
url_parts[4] = _urlencode(query)
|
||||||
|
return parse.urlunparse(url_parts)
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mutagen import id3
|
from mutagen import id3
|
||||||
|
|
||||||
from ucast.models import Video
|
|
||||||
|
|
||||||
|
def tag_audio(
|
||||||
def tag_audio(audio_path: Path, video: Video, cover_path: Path):
|
audio_path: Path,
|
||||||
title_text = f"{video.published.date().isoformat()} {video.title}"
|
title: str,
|
||||||
|
channel: str,
|
||||||
|
published: date,
|
||||||
|
description: str,
|
||||||
|
cover_path: Path,
|
||||||
|
):
|
||||||
|
title_text = f"{published.isoformat()} {title}"
|
||||||
|
|
||||||
tag = id3.ID3(audio_path)
|
tag = id3.ID3(audio_path)
|
||||||
tag["TPE1"] = id3.TPE1(encoding=3, text=video.channel.name) # Artist
|
tag["TPE1"] = id3.TPE1(encoding=3, text=channel) # Artist
|
||||||
tag["TALB"] = id3.TALB(encoding=3, text=video.channel.name) # Album
|
tag["TALB"] = id3.TALB(encoding=3, text=channel) # Album
|
||||||
tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title
|
tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title
|
||||||
tag["TDRC"] = id3.TDRC(encoding=3, text=video.published.date().isoformat()) # Date
|
tag["TDRC"] = id3.TDRC(encoding=3, text=published.isoformat()) # Date
|
||||||
tag["COMM"] = id3.COMM(encoding=3, text=video.get_full_description()) # Comment
|
tag["COMM"] = id3.COMM(encoding=3, text=description) # Comment
|
||||||
|
|
||||||
with open(cover_path, "rb") as albumart:
|
with open(cover_path, "rb") as albumart:
|
||||||
tag["APIC"] = id3.APIC(
|
tag["APIC"] = id3.APIC(
|
||||||
|
|
|
@ -5,7 +5,7 @@ import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Generator, List, Optional
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
|
@ -96,6 +96,7 @@ class ChannelMetadata:
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
avatar_url: str
|
avatar_url: str
|
||||||
|
subscribers: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
def download_thumbnail(vinfo: VideoDetails, download_path: Path):
|
def download_thumbnail(vinfo: VideoDetails, download_path: Path):
|
||||||
|
@ -215,6 +216,15 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata:
|
||||||
name = metadata["title"]
|
name = metadata["title"]
|
||||||
description = metadata["description"].strip()
|
description = metadata["description"].strip()
|
||||||
avatar = metadata["avatar"]["thumbnails"][0]["url"]
|
avatar = metadata["avatar"]["thumbnails"][0]["url"]
|
||||||
|
subscribers = None
|
||||||
|
# The subscriber count is not always visible
|
||||||
|
try:
|
||||||
|
raw_subscribers = data["header"]["c4TabbedHeaderRenderer"][
|
||||||
|
"subscriberCountText"
|
||||||
|
]["simpleText"]
|
||||||
|
subscribers = raw_subscribers.split(" ", 1)[0]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
if not CHANID_REGEX.match(channel_id):
|
if not CHANID_REGEX.match(channel_id):
|
||||||
raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}")
|
raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}")
|
||||||
|
@ -227,7 +237,7 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata:
|
||||||
f"got invalid avatar url for channel {channel_id}: {avatar}"
|
f"got invalid avatar url for channel {channel_id}: {avatar}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return ChannelMetadata(channel_id, name, description, avatar)
|
return ChannelMetadata(channel_id, name, description, avatar, subscribers)
|
||||||
|
|
||||||
|
|
||||||
def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
|
def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
|
||||||
|
@ -262,16 +272,14 @@ def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
|
||||||
|
|
||||||
def get_channel_videos_from_scraper(
|
def get_channel_videos_from_scraper(
|
||||||
channel_id: str, limit: int = None
|
channel_id: str, limit: int = None
|
||||||
) -> List[VideoScraped]:
|
) -> Generator[VideoScraped, None, None]:
|
||||||
"""
|
"""
|
||||||
Return all videos of a channel by scraping the YouTube website.
|
Return all videos of a channel by scraping the YouTube website.
|
||||||
May take a while depending on the number of videos.
|
|
||||||
|
|
||||||
:param channel_id: YouTube channel id
|
:param channel_id: YouTube channel id
|
||||||
:param limit: Limit number of scraped videos
|
:param limit: Limit number of scraped videos
|
||||||
:return: Videos: video_id -> VideoScraped
|
:return: Generator of Videos
|
||||||
"""
|
"""
|
||||||
videos = []
|
|
||||||
|
|
||||||
for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit):
|
for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit):
|
||||||
video_id = item.get("videoId")
|
video_id = item.get("videoId")
|
||||||
|
@ -281,6 +289,4 @@ def get_channel_videos_from_scraper(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
videos.append(VideoScraped(video_id, None))
|
yield VideoScraped(video_id, None)
|
||||||
|
|
||||||
return videos
|
|
||||||
|
|
2
ucast/static/ucast/logo.svg
Normal file
2
ucast/static/ucast/logo.svg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#282828"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#282828" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#282828"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#282828"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#282828"/></g></svg>
|
After Width: | Height: | Size: 858 B |
2
ucast/static/ucast/logo_dark.svg
Normal file
2
ucast/static/ucast/logo_dark.svg
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#fff"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#fff" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#fff"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#fff"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#fff"/></g></svg>
|
After Width: | Height: | Size: 843 B |
|
@ -7,48 +7,55 @@ from ucast.models import Channel, Video
|
||||||
from ucast.service import cover, storage, util, videoutil, youtube
|
from ucast.service import cover, storage, util, videoutil, youtube
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_channel(channel_id: str) -> Channel:
|
def _get_or_create_channel(channel_str: str) -> Channel:
|
||||||
|
if youtube.CHANID_REGEX.match(channel_str):
|
||||||
try:
|
try:
|
||||||
return Channel.objects.get(id=channel_id)
|
return Channel.objects.get(channel_id=channel_str)
|
||||||
except Channel.DoesNotExist:
|
except Channel.DoesNotExist:
|
||||||
channel_data = youtube.get_channel_metadata(
|
pass
|
||||||
youtube.channel_url_from_id(channel_id)
|
|
||||||
)
|
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)
|
channel_slug = Channel.get_new_slug(channel_data.name)
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
channel_folder = store.get_channel_folder(channel_slug)
|
channel_folder = store.get_or_create_channel_folder(channel_slug)
|
||||||
|
|
||||||
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
|
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
|
||||||
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
|
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
|
||||||
|
|
||||||
channel = Channel(
|
channel = Channel(
|
||||||
id=channel_id,
|
channel_id=channel_data.id,
|
||||||
name=channel_data.name,
|
name=channel_data.name,
|
||||||
slug=channel_slug,
|
slug=channel_slug,
|
||||||
description=channel_data.description,
|
description=channel_data.description,
|
||||||
|
subscribers=channel_data.subscribers,
|
||||||
)
|
)
|
||||||
channel.save()
|
channel.save()
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
||||||
if Video.objects.filter(id=vid.id).exists():
|
if Video.objects.filter(video_id=vid.id).exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
details = youtube.get_video_details(vid.id)
|
details = youtube.get_video_details(vid.id)
|
||||||
|
|
||||||
# Check filter
|
# Dont load active livestreams
|
||||||
if (
|
if details.is_currently_live:
|
||||||
details.is_currently_live
|
|
||||||
or (details.is_short and channel.skip_shorts)
|
|
||||||
or (details.is_livestream and channel.skip_livestreams)
|
|
||||||
):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
slug = Video.get_new_slug(details.title, details.published.date(), channel.id)
|
slug = Video.get_new_slug(
|
||||||
|
details.title, details.published.date(), channel.channel_id
|
||||||
|
)
|
||||||
|
|
||||||
video = Video(
|
video = Video(
|
||||||
id=details.id,
|
video_id=details.id,
|
||||||
title=details.title,
|
title=details.title,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
@ -60,6 +67,7 @@ def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
||||||
)
|
)
|
||||||
video.save()
|
video.save()
|
||||||
|
|
||||||
|
if channel.should_download(video):
|
||||||
queue.enqueue(download_video, video)
|
queue.enqueue(download_video, video)
|
||||||
|
|
||||||
|
|
||||||
|
@ -71,10 +79,10 @@ def download_video(video: Video):
|
||||||
:param video: Video object
|
:param video: Video object
|
||||||
"""
|
"""
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
channel_folder = store.get_channel_folder(video.channel.slug)
|
channel_folder = store.get_or_create_channel_folder(video.channel.slug)
|
||||||
|
|
||||||
audio_file = channel_folder.get_audio(video.slug)
|
audio_file = channel_folder.get_audio(video.slug)
|
||||||
details = youtube.download_audio(video.id, audio_file)
|
details = youtube.download_audio(video.video_id, audio_file)
|
||||||
|
|
||||||
# Download/convert thumbnails
|
# Download/convert thumbnails
|
||||||
tn_path = channel_folder.get_thumbnail(video.slug)
|
tn_path = channel_folder.get_thumbnail(video.slug)
|
||||||
|
@ -90,34 +98,39 @@ def download_video(video: Video):
|
||||||
cover_file,
|
cover_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
videoutil.tag_audio(audio_file, video, cover_file)
|
videoutil.tag_audio(
|
||||||
|
audio_file,
|
||||||
|
video.title,
|
||||||
|
video.channel.name,
|
||||||
|
video.published.date(),
|
||||||
|
video.get_full_description(),
|
||||||
|
cover_file,
|
||||||
|
)
|
||||||
|
|
||||||
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.save()
|
video.save()
|
||||||
|
|
||||||
|
|
||||||
def import_channel(channel_id: str, limit: int = None):
|
def import_channel(channel_str: str, limit: int = None):
|
||||||
"""
|
"""
|
||||||
Add a new channel to ucast and download all existing videos.
|
Add a new channel to ucast and download all existing videos.
|
||||||
|
|
||||||
:param channel_id: YT-Channel-ID
|
:param channel_str: YT-Channel-ID / URL
|
||||||
:param limit: Maximum number of videos to download
|
:param limit: Maximum number of videos to download
|
||||||
"""
|
"""
|
||||||
channel = _get_or_create_channel(channel_id)
|
channel = _get_or_create_channel(channel_str)
|
||||||
|
|
||||||
if limit == 0:
|
if limit == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
videos = youtube.get_channel_videos_from_scraper(channel_id, limit)
|
for vid in youtube.get_channel_videos_from_scraper(channel.channel_id, limit):
|
||||||
|
|
||||||
for vid in videos[:limit]:
|
|
||||||
_load_scraped_video(vid, channel)
|
_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.id)
|
videos = youtube.get_channel_videos_from_feed(channel.channel_id)
|
||||||
|
|
||||||
for vid in videos:
|
for vid in videos:
|
||||||
_load_scraped_video(vid, channel)
|
_load_scraped_video(vid, channel)
|
||||||
|
|
|
@ -4,7 +4,7 @@ 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, youtube
|
from ucast.service import cover, storage, util, videoutil, youtube
|
||||||
|
|
||||||
|
|
||||||
def recreate_cover(video: Video):
|
def recreate_cover(video: Video):
|
||||||
|
@ -13,12 +13,13 @@ def recreate_cover(video: Video):
|
||||||
|
|
||||||
thumbnail_file = cf.get_thumbnail(video.slug)
|
thumbnail_file = cf.get_thumbnail(video.slug)
|
||||||
cover_file = cf.get_cover(video.slug)
|
cover_file = cf.get_cover(video.slug)
|
||||||
|
audio_file = cf.get_audio(video.slug)
|
||||||
|
|
||||||
if not os.path.isfile(cf.file_avatar):
|
if not os.path.isfile(cf.file_avatar):
|
||||||
raise FileNotFoundError(f"could not find avatar for channel {video.channel_id}")
|
raise FileNotFoundError(f"could not find avatar for channel {video.channel_id}")
|
||||||
|
|
||||||
if not os.path.isfile(thumbnail_file):
|
if not os.path.isfile(thumbnail_file):
|
||||||
raise FileNotFoundError(f"could not find thumbnail for video {video.id}")
|
raise FileNotFoundError(f"could not find thumbnail for video {video.video_id}")
|
||||||
|
|
||||||
cover.create_cover_file(
|
cover.create_cover_file(
|
||||||
thumbnail_file,
|
thumbnail_file,
|
||||||
|
@ -29,6 +30,15 @@ def recreate_cover(video: Video):
|
||||||
cover_file,
|
cover_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
videoutil.tag_audio(
|
||||||
|
audio_file,
|
||||||
|
video.title,
|
||||||
|
video.channel.name,
|
||||||
|
video.published.date(),
|
||||||
|
video.get_full_description(),
|
||||||
|
cover_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def recreate_covers():
|
def recreate_covers():
|
||||||
for video in Video.objects.filter(downloaded__isnull=False):
|
for video in Video.objects.filter(downloaded__isnull=False):
|
||||||
|
@ -39,7 +49,13 @@ def update_file_storage():
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
|
|
||||||
for video in Video.objects.all():
|
for video in Video.objects.all():
|
||||||
|
try:
|
||||||
cf = store.get_channel_folder(video.channel.slug)
|
cf = store.get_channel_folder(video.channel.slug)
|
||||||
|
except FileNotFoundError:
|
||||||
|
video.downloaded = None
|
||||||
|
video.download_size = None
|
||||||
|
video.save()
|
||||||
|
return
|
||||||
|
|
||||||
audio_file = cf.get_audio(video.slug)
|
audio_file = cf.get_audio(video.slug)
|
||||||
cover_file = cf.get_cover(video.slug)
|
cover_file = cf.get_cover(video.slug)
|
||||||
|
@ -66,11 +82,13 @@ def update_file_storage():
|
||||||
|
|
||||||
|
|
||||||
def update_channel_info(channel: Channel):
|
def update_channel_info(channel: Channel):
|
||||||
channel_data = youtube.get_channel_metadata(youtube.channel_url_from_id(channel.id))
|
channel_data = youtube.get_channel_metadata(
|
||||||
|
youtube.channel_url_from_id(channel.channel_id)
|
||||||
|
)
|
||||||
|
|
||||||
if channel_data.avatar_url != channel.avatar_url:
|
if channel_data.avatar_url != channel.avatar_url:
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
channel_folder = store.get_channel_folder(channel.slug)
|
channel_folder = store.get_or_create_channel_folder(channel.slug)
|
||||||
|
|
||||||
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
|
util.download_image_file(channel_data.avatar_url, channel_folder.file_avatar)
|
||||||
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
|
util.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
|
||||||
|
@ -79,6 +97,7 @@ def update_channel_info(channel: Channel):
|
||||||
|
|
||||||
channel.name = channel_data.name
|
channel.name = channel_data.name
|
||||||
channel.description = channel_data.description
|
channel.description = channel_data.description
|
||||||
|
channel.subscribers = channel_data.subscribers
|
||||||
|
|
||||||
channel.save()
|
channel.save()
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from datetime import datetime
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from ucast import queue
|
from ucast import queue
|
||||||
from ucast.tasks import download
|
from ucast.tasks import download, library
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -26,3 +26,10 @@ def register_scheduled_jobs():
|
||||||
id="schedule_update_channels",
|
id="schedule_update_channels",
|
||||||
interval=settings.YT_UPDATE_INTERVAL,
|
interval=settings.YT_UPDATE_INTERVAL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scheduler.schedule(
|
||||||
|
datetime.utcnow(),
|
||||||
|
library.update_channel_infos,
|
||||||
|
id="schedule_update_channel_infos",
|
||||||
|
interval=24 * 3600,
|
||||||
|
)
|
||||||
|
|
77
ucast/templates/bulma/base.html
Normal file
77
ucast/templates/bulma/base.html
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
{% load static bulma_tags %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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 'bulma/css/style.min.css' %}">
|
||||||
|
{% block extra_css %}{% endblock extra_css %}
|
||||||
|
{% endblock css %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<section>
|
||||||
|
<nav class="navbar is-dark">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a class="navbar-item" href="/">
|
||||||
|
<img src="{% static 'ucast/logo_dark.svg' %}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
{% url 'login' as login_url %}
|
||||||
|
{% url 'logout' as logout_url %}
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<a class="navbar-item is-hidden-desktop-only" href="{{ logout_url }}">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="navbar-item is-hidden-desktop-only" href="{{ login_url }}">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
{% endblock header %}
|
||||||
|
|
||||||
|
{% block hero %}{% endblock hero %}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
{% block messages %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages columns is-desktop">
|
||||||
|
<div class="column is-4 is-offset-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div
|
||||||
|
class="message {% if message.tags %}is-{{ message.tags|bulma_message_tag }}{% endif %}">
|
||||||
|
<div class="message-body">{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock messages %}
|
||||||
|
|
||||||
|
{% block content_area %}
|
||||||
|
{% block content_title %}{% endblock content_title %}
|
||||||
|
{% block content %}{% endblock content %}
|
||||||
|
{% endblock content_area %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% block modal %}{% endblock modal %}
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
{% endblock footer %}
|
||||||
|
|
||||||
|
{% block javascript %}
|
||||||
|
{% block extra_javascript %}{% endblock extra_javascript %}
|
||||||
|
{% endblock javascript %}
|
||||||
|
</body>
|
||||||
|
</html>
|
48
ucast/templates/ucast/channels.html
Normal file
48
ucast/templates/ucast/channels.html
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}ucast - Channels{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="title">Channels</h1>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<form action="" 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">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
<i class="fas fa-add"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for channel in channels %}
|
||||||
|
<div class="box is-flex">
|
||||||
|
<div class="is-flex">
|
||||||
|
<a href="/channel/{{ 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 is-flex-grow-1">
|
||||||
|
<input class="input" type="text"
|
||||||
|
value="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"
|
||||||
|
readonly>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock content %}
|
|
@ -1,11 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Ucast</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Ucast</h1>
|
|
||||||
Hello World!
|
|
||||||
</body>
|
|
||||||
</html>
|
|
94
ucast/templates/ucast/videos.html
Normal file
94
ucast/templates/ucast/videos.html
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}ucast - Videos{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="level">
|
||||||
|
<h1 class="title">{{ channel.name }}</h1>
|
||||||
|
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag"><i
|
||||||
|
class="fas fa-user-group"></i> {{ channel.subscribers }}</span>
|
||||||
|
<span class="tag"><i
|
||||||
|
class="fas fa-video"></i> {{ channel.video_set.count }}</span>
|
||||||
|
<span class="tag"><i
|
||||||
|
class="fas fa-database"></i> {{ channel.download_size|filesizeformat }}</span>
|
||||||
|
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
|
||||||
|
class="fa-brands fa-youtube"></i> {{ 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> {{ video.published|date }}</span>
|
||||||
|
<span class="tag"><i
|
||||||
|
class="fas fa-database"></i> {{ video.download_size|filesizeformat }}</span>
|
||||||
|
<a class="tag" href="{{ video.get_absolute_url }}" target="_blank"><i
|
||||||
|
class="fa-brands fa-youtube"></i> {{ video.video_id }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-primary">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock content %}
|
|
@ -11,7 +11,7 @@ DIR_TESTFILES = resources.path("ucast.tests", "_testfiles")
|
||||||
|
|
||||||
|
|
||||||
def get_video_details(video_id: str):
|
def get_video_details(video_id: str):
|
||||||
with open(DIR_TESTFILES / "fixture" / "videodetails.json") as f:
|
with open(DIR_TESTFILES / "object" / "videodetails.json") as f:
|
||||||
videodetails = json.load(f)
|
videodetails = json.load(f)
|
||||||
|
|
||||||
vd_raw = videodetails[video_id]
|
vd_raw = videodetails[video_id]
|
||||||
|
@ -21,7 +21,7 @@ def get_video_details(video_id: str):
|
||||||
|
|
||||||
|
|
||||||
def get_channel_metadata(channel_url: str):
|
def get_channel_metadata(channel_url: str):
|
||||||
with open(DIR_TESTFILES / "fixture" / "channelmeta.json") as f:
|
with open(DIR_TESTFILES / "object" / "channelmeta.json") as f:
|
||||||
channelmeta = json.load(f)
|
channelmeta = json.load(f)
|
||||||
|
|
||||||
return youtube.ChannelMetadata(**channelmeta[channel_url])
|
return youtube.ChannelMetadata(**channelmeta[channel_url])
|
||||||
|
|
|
@ -1,54 +1,57 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"model": "ucast.channel",
|
"model": "ucast.channel",
|
||||||
"pk": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"pk": 1,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"channel_id": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
||||||
"name": "ThetaDev",
|
"name": "ThetaDev",
|
||||||
"slug": "ThetaDev",
|
"slug": "ThetaDev",
|
||||||
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
|
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
|
||||||
|
"subscribers": "37",
|
||||||
"active": true,
|
"active": true,
|
||||||
"skip_livestreams": true,
|
"skip_livestreams": true,
|
||||||
"skip_shorts": true,
|
"skip_shorts": true,
|
||||||
"keep_videos": null,
|
|
||||||
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.channel",
|
"model": "ucast.channel",
|
||||||
"pk": "UC2TXq_t06Hjdr2g_KdKpHQg",
|
"pk": 2,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"channel_id": "UC2TXq_t06Hjdr2g_KdKpHQg",
|
||||||
"name": "media.ccc.de",
|
"name": "media.ccc.de",
|
||||||
"slug": "media_ccc_de",
|
"slug": "media_ccc_de",
|
||||||
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
|
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
|
||||||
|
"subscribers": "166K",
|
||||||
"active": true,
|
"active": true,
|
||||||
"skip_livestreams": true,
|
"skip_livestreams": true,
|
||||||
"skip_shorts": true,
|
"skip_shorts": true,
|
||||||
"keep_videos": null,
|
|
||||||
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.channel",
|
"model": "ucast.channel",
|
||||||
"pk": "UCmLTTbctUZobNQrr8RtX8uQ",
|
"pk": 3,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"channel_id": "UCmLTTbctUZobNQrr8RtX8uQ",
|
||||||
"name": "Creative Commons",
|
"name": "Creative Commons",
|
||||||
"slug": "Creative_Commons",
|
"slug": "Creative_Commons",
|
||||||
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
|
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
|
||||||
|
"subscribers": null,
|
||||||
"active": true,
|
"active": true,
|
||||||
"skip_livestreams": true,
|
"skip_livestreams": true,
|
||||||
"skip_shorts": true,
|
"skip_shorts": true,
|
||||||
"keep_videos": null,
|
|
||||||
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "ZPxEr4YdWt8",
|
"pk": 1,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "ZPxEr4YdWt8",
|
||||||
"title": "ThetaDev @ Embedded World 2019",
|
"title": "ThetaDev @ Embedded World 2019",
|
||||||
"slug": "20190602_ThetaDev_Embedded_World_2019",
|
"slug": "20190602_ThetaDev_Embedded_World_2019",
|
||||||
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"channel": 1,
|
||||||
"published": "2019-06-02T00:00:00Z",
|
"published": "2019-06-02T00:00:00Z",
|
||||||
"downloaded": "2022-05-15T22:16:03.096Z",
|
"downloaded": "2022-05-15T22:16:03.096Z",
|
||||||
"description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
"description": "This february I spent one day at the Embedded World in Nuremberg. They showed tons of interesting electronics stuff, so I had to take some pictures and videos for you to see ;-)\n\nSorry for the late upload, I just didn't have time to edit my footage.\n\nEmbedded World: https://www.embedded-world.de/\n\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
||||||
|
@ -60,11 +63,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "_I5IFObm_-k",
|
"pk": 2,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "_I5IFObm_-k",
|
||||||
"title": "Easter special: 3D printed Bunny",
|
"title": "Easter special: 3D printed Bunny",
|
||||||
"slug": "20180331_Easter_special_3D_printed_Bunny",
|
"slug": "20180331_Easter_special_3D_printed_Bunny",
|
||||||
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"channel": 1,
|
||||||
"published": "2018-03-31T00:00:00Z",
|
"published": "2018-03-31T00:00:00Z",
|
||||||
"downloaded": "2022-05-15T22:16:12.514Z",
|
"downloaded": "2022-05-15T22:16:12.514Z",
|
||||||
"description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
"description": "Happy Easter 2018!\nThis is just a special video where I print a little bunny as an Easter gift for friends or relatives. I hope you like the model, too.\n\nSadly my camera doesn't support timelapses, so I had to record the whole 4h printing process in real time, resulting in 30GB of footage. But I think it was worth it ;-)\n\n__PROJECT_LINKS___________________________\nBunny: https://www.thingiverse.com/thing:287884\n\n__COMPONENT_SUPPLIERS__________________\n3D printer: https://www.prusa3d.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
||||||
|
@ -76,11 +80,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "mmEDPbbSnaY",
|
"pk": 3,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "mmEDPbbSnaY",
|
||||||
"title": "ThetaDevlog#2 - MySensors singleLED",
|
"title": "ThetaDevlog#2 - MySensors singleLED",
|
||||||
"slug": "20180326_ThetaDevlog_2_MySensors_singleLED",
|
"slug": "20180326_ThetaDevlog_2_MySensors_singleLED",
|
||||||
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"channel": 1,
|
||||||
"published": "2018-03-26T00:00:00Z",
|
"published": "2018-03-26T00:00:00Z",
|
||||||
"downloaded": "2022-05-15T22:16:20.280Z",
|
"downloaded": "2022-05-15T22:16:20.280Z",
|
||||||
"description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com",
|
"description": "The PCBs and components for the MySensors smart home devices arrived!\nIn this video I'll show you how to build the singleLED controller to switch/dim your 12V led lights. Detailed building instructions can be found on OpenHardware or GitHub.\n\n__PROJECT_LINKS___________________________\nOpenHardware: https://www.openhardware.io/view/563\nGitHub: https://github.com/Theta-Dev/MySensors-singleLED\n\nProgramming adapter: https://thdev.org/?Projects___misc___micro_JST\nBoard definitions: http://files.thdev.org/arduino/atmega.zip\n\n__COMPONENT_SUPPLIERS__________________\nElectronic components: https://www.aliexpress.com/\nPCBs: http://www.allpcb.com/\n3D printing filament: https://www.dasfilament.de/\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev\n______________________________________________\nMusic by Bartlebeats: https://bartlebeats.bandcamp.com",
|
||||||
|
@ -92,11 +97,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "Cda4zS-1j-k",
|
"pk": 4,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "Cda4zS-1j-k",
|
||||||
"title": "ThetaDevlog#1 - MySensors Smart Home!",
|
"title": "ThetaDevlog#1 - MySensors Smart Home!",
|
||||||
"slug": "20180217_ThetaDevlog_1_MySensors_Smart_Home",
|
"slug": "20180217_ThetaDevlog_1_MySensors_Smart_Home",
|
||||||
"channel": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"channel": 1,
|
||||||
"published": "2018-02-17T00:00:00Z",
|
"published": "2018-02-17T00:00:00Z",
|
||||||
"downloaded": "2022-05-15T22:16:25.237Z",
|
"downloaded": "2022-05-15T22:16:25.237Z",
|
||||||
"description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\n\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\n\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
"description": "Smart Home devices have been around for some time and can really make your life easier. But most of them are quite pricey and not always worth the money.\n\nHow about a sytem that costs only 5€ per device and has all the benefits of the expensive solutions? The open source project MySensors claims to do that. In this series I'll try this and find out whether it works!\n\n______________________________________________\nMy website: https://thdev.org\nTwitter: https://twitter.com/Theta_Dev",
|
||||||
|
@ -108,11 +114,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "2xfXsqyd8YA",
|
"pk": 5,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "2xfXsqyd8YA",
|
||||||
"title": "cy: Log4Shell - Bug oder Feature",
|
"title": "cy: Log4Shell - Bug oder Feature",
|
||||||
"slug": "20220521_cy_Log4Shell_Bug_oder_Feature",
|
"slug": "20220521_cy_Log4Shell_Bug_oder_Feature",
|
||||||
"channel": "UC2TXq_t06Hjdr2g_KdKpHQg",
|
"channel": 2,
|
||||||
"published": "2022-05-21T00:00:00Z",
|
"published": "2022-05-21T00:00:00Z",
|
||||||
"downloaded": null,
|
"downloaded": null,
|
||||||
"description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security",
|
"description": "https://media.ccc.de/v/gpn20-60-log4shell-bug-oder-feature\n\n\n\nUm den Jahreswechsel ging ein Aufschrei durch die IT-Abteilungen der Welt, der es bis in die Mainstream-Medien geschafft hat. Noch Wochen später zeigen sich Folgeprobleme in weit verbreiteter Software.\n \nIn Log4j, einer weit verbreiteten Java-Bibliothek wurde eine massive Sicherheitslücke gefunden, die die Ausführung von Schadcode auf einem entfernten System erlaubt.\nIn diesem Vortrag soll rekapitulierend erklärt werden, warum und wann es zu dem Problem kam und welche Auswirkungen bisher erkennbar sind. Ausserdem werden die technischen Details der Schwachstelle erklärt und in einer Live-Demo gezeigt, wie die Schwachstelle ausgenutzt werden kann.\n\n\n\ncy\n\nhttps://cfp.gulas.ch/gpn20/talk/77BCXN/\n\n#gpn20 #Security",
|
||||||
|
@ -124,11 +131,12 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "ucast.video",
|
"model": "ucast.video",
|
||||||
"pk": "I0RRENheeTo",
|
"pk": 6,
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"video_id": "I0RRENheeTo",
|
||||||
"title": "No copyright intro free fire intro | no text | free copy right | free templates | free download",
|
"title": "No copyright intro free fire intro | no text | free copy right | free templates | free download",
|
||||||
"slug": "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download",
|
"slug": "20211010_No_copyright_intro_free_fire_intro_no_text_free_copy_right_free_templates_free_download",
|
||||||
"channel": "UCmLTTbctUZobNQrr8RtX8uQ",
|
"channel": 3,
|
||||||
"published": "2021-10-10T00:00:00Z",
|
"published": "2021-10-10T00:00:00Z",
|
||||||
"downloaded": null,
|
"downloaded": null,
|
||||||
"description": "Like Video▬▬▬▬▬❤\uD83D\uDC4D❤\n▬▬\uD83D\uDC47SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES\uD83D\uDC46▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬\uD83C\uDF81...Share Video To Friends...\uD83C\uDF81▬▬▬▬▬▬▬\n▬▬▬▬\uD83E\uDD14...Comment Any Questions....\uD83E\uDD14▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags\uD83D\uDC47▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro",
|
"description": "Like Video▬▬▬▬▬❤\uD83D\uDC4D❤\n▬▬\uD83D\uDC47SUBSCRIBE OUR CHANNEL FOR LATEST UPDATES\uD83D\uDC46▬▬\nThis Channel: https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ?sub_confirmation=1\nOther Channel: https://www.youtube.com/channel/UCKtfYFXi5A4KLIUdjgvfmHg?sub_confirmation=1\n▬▬▬▬▬▬▬▬/Subscription Free\\▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬\uD83C\uDF81...Share Video To Friends...\uD83C\uDF81▬▬▬▬▬▬▬\n▬▬▬▬\uD83E\uDD14...Comment Any Questions....\uD83E\uDD14▬▬▬▬▬▬\nHello friends, \n Shahzaib Hassan and you are watching Creative Commons YouTube channel. On this channel, you will find all the videos absolutely free copyright which you can download and use in any project.\n It is copyright free so you won't have any problem using end screen for YouTube. if you use it or download and reupload it to your channel. By doing this you can use it for YouTube its use is absolutely free.\n ►I hope you'll like the video.◄\n ►Thanks For Watching◄ \nIf you really like this video then please don't forget to...\n\n\n▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬\n▬▬▬▬▬▬▬▬▬▬Tags\uD83D\uDC47▬▬▬▬▬▬▬▬▬▬\n#Creativecommons #commoncreative #free #freecopyright #nocopyright #nowatermark #freetouse #intro #notext #fireefire #channelintro",
|
||||||
|
@ -137,5 +145,24 @@
|
||||||
"is_short": false,
|
"is_short": false,
|
||||||
"download_size": null
|
"download_size": null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "ucast.user",
|
||||||
|
"pk": 1,
|
||||||
|
"fields": {
|
||||||
|
"password": "pbkdf2_sha256$320000$2XXzT2OZlzSOnB1n7NCgAB$9zBJXdGbJv9YnS+kP5RUMkGxeIuqAbDRBBzXlmPJizw=",
|
||||||
|
"last_login": "2022-05-29T21:08:21.383Z",
|
||||||
|
"is_superuser": true,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": "",
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"is_staff": true,
|
||||||
|
"is_active": true,
|
||||||
|
"date_joined": "2022-05-29T21:05:24.014Z",
|
||||||
|
"feed_key": null,
|
||||||
|
"groups": [],
|
||||||
|
"user_permissions": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
|
@ -3,18 +3,21 @@
|
||||||
"id": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
"id": "UCGiJh0NZ52wRhYKYnuZI08Q",
|
||||||
"name": "ThetaDev",
|
"name": "ThetaDev",
|
||||||
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
|
"description": "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff.",
|
||||||
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj",
|
||||||
|
"subscribers": "37"
|
||||||
},
|
},
|
||||||
"https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg": {
|
"https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg": {
|
||||||
"id": "UC2TXq_t06Hjdr2g_KdKpHQg",
|
"id": "UC2TXq_t06Hjdr2g_KdKpHQg",
|
||||||
"name": "media.ccc.de",
|
"name": "media.ccc.de",
|
||||||
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
|
"description": "The real official channel of the chaos computer club, operated by the CCC VOC (https://c3voc.de)",
|
||||||
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/c1jcNSbPuOMDUieixkWIlXc82kMNJ8pCDmq5KtL8hjt74rAXLobsT9Y078-w5DK7ymKyDaqr=s900-c-k-c0x00ffffff-no-rj",
|
||||||
|
"subscribers": "166K"
|
||||||
},
|
},
|
||||||
"https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ": {
|
"https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ": {
|
||||||
"id": "UCmLTTbctUZobNQrr8RtX8uQ",
|
"id": "UCmLTTbctUZobNQrr8RtX8uQ",
|
||||||
"name": "Creative Commons",
|
"name": "Creative Commons",
|
||||||
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
|
"description": "Hello friends,\nWelcome to my channel CREATIVE COMMONS.\nOn this channel you will get all the videos absolutely free copyright and no matter how many videos you download there is no copyright claim you can download them and upload them to your channel and all the music is young Is on the channel they can also download and use in their videos on this channel you will find different videos in which OUTRO Videos, INTRO Videos, FREE MUSIC, FREE SOUND EFFECTS, LOWER THIRDS, and more.",
|
||||||
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj"
|
"avatar_url": "https://yt3.ggpht.com/-ybcsEHc8YCmKUZMr2bf4DZoDv7SKrutgKIh8kSxXugj296QkqtBZQXVzpuZ1Izs8kNUz35B=s900-c-k-c0x00ffffff-no-rj",
|
||||||
|
"subscribers": null
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from typing import Tuple
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -10,19 +14,24 @@ from django.core.management import call_command
|
||||||
from fakeredis import FakeRedis
|
from fakeredis import FakeRedis
|
||||||
|
|
||||||
from ucast import queue, tests
|
from ucast import queue, tests
|
||||||
from ucast.models import Video
|
from ucast.models import User
|
||||||
from ucast.service import cover, storage, util, videoutil, youtube
|
from ucast.service import cover, storage, util, videoutil, youtube
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def default_config():
|
||||||
|
settings.DOWNLOAD_ROOT = Path("does/not/exist")
|
||||||
|
settings.REDIS_URL = "no redis"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def django_db_setup(django_db_setup, django_db_blocker):
|
def django_db_setup(django_db_setup, django_db_blocker):
|
||||||
with django_db_blocker.unblock():
|
with django_db_blocker.unblock():
|
||||||
fixture_path = tests.DIR_TESTFILES / "fixture" / "videos.json"
|
fixture_path = tests.DIR_TESTFILES / "fixture" / "models.json"
|
||||||
call_command("loaddata", fixture_path)
|
call_command("loaddata", fixture_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def _create_download_dir() -> Tuple[Path, TemporaryDirectory]:
|
||||||
def download_dir() -> Path:
|
|
||||||
tmpdir_o = tempfile.TemporaryDirectory()
|
tmpdir_o = tempfile.TemporaryDirectory()
|
||||||
tmpdir = Path(tmpdir_o.name)
|
tmpdir = Path(tmpdir_o.name)
|
||||||
settings.DOWNLOAD_ROOT = tmpdir
|
settings.DOWNLOAD_ROOT = tmpdir
|
||||||
|
@ -35,40 +44,76 @@ def download_dir() -> Path:
|
||||||
("media_ccc_de", "a3"),
|
("media_ccc_de", "a3"),
|
||||||
("Creative_Commons", "a4"),
|
("Creative_Commons", "a4"),
|
||||||
):
|
):
|
||||||
cf = store.get_channel_folder(slug)
|
cf = store.get_or_create_channel_folder(slug)
|
||||||
shutil.copyfile(
|
shutil.copyfile(
|
||||||
tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar
|
tests.DIR_TESTFILES / "avatar" / f"{avatar}.jpg", cf.file_avatar
|
||||||
)
|
)
|
||||||
util.resize_avatar(cf.file_avatar, cf.file_avatar_sm)
|
util.resize_avatar(cf.file_avatar, cf.file_avatar_sm)
|
||||||
|
|
||||||
|
return tmpdir, tmpdir_o
|
||||||
|
|
||||||
|
|
||||||
|
def _add_download_dir_content():
|
||||||
|
store = storage.Storage()
|
||||||
|
|
||||||
|
with open(tests.DIR_TESTFILES / "object" / "videodetails.json") as f:
|
||||||
|
videodetails = json.load(f)
|
||||||
|
|
||||||
|
for vid in ("ZPxEr4YdWt8", "_I5IFObm_-k", "mmEDPbbSnaY", "Cda4zS-1j-k"):
|
||||||
|
video_detail = videodetails[vid]
|
||||||
|
channel_name = video_detail["channel_name"]
|
||||||
|
channel_slug = util.get_slug(channel_name)
|
||||||
|
published = datetime.fromisoformat(video_detail["published"])
|
||||||
|
title = video_detail["title"]
|
||||||
|
video_slug = util.get_slug(f"{published.strftime('%Y%m%d')}_{title}")
|
||||||
|
description = video_detail["description"]
|
||||||
|
|
||||||
|
cf = store.get_or_create_channel_folder(channel_slug)
|
||||||
|
file_audio = cf.get_audio(video_slug)
|
||||||
|
file_tn = cf.get_thumbnail(video_slug)
|
||||||
|
file_cover = cf.get_cover(video_slug)
|
||||||
|
|
||||||
|
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio)
|
||||||
|
shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{vid}.webp", file_tn)
|
||||||
|
util.resize_thumbnail(file_tn, cf.get_thumbnail(video_slug, True))
|
||||||
|
cover.create_cover_file(
|
||||||
|
file_tn,
|
||||||
|
cf.file_avatar,
|
||||||
|
title,
|
||||||
|
channel_name,
|
||||||
|
cover.COVER_STYLE_BLUR,
|
||||||
|
file_cover,
|
||||||
|
)
|
||||||
|
videoutil.tag_audio(
|
||||||
|
file_audio,
|
||||||
|
title,
|
||||||
|
channel_name,
|
||||||
|
published.date(),
|
||||||
|
f"{description}\n\nhttps://youtu.be/{vid}",
|
||||||
|
file_cover,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def download_dir() -> Path:
|
||||||
|
tmpdir, tmpdir_o = _create_download_dir()
|
||||||
|
yield tmpdir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def download_dir_content() -> Path:
|
||||||
|
tmpdir, tmpdir_o = _create_download_dir()
|
||||||
|
settings.DOWNLOAD_ROOT = tmpdir
|
||||||
|
_add_download_dir_content()
|
||||||
yield tmpdir
|
yield tmpdir
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@pytest.mark.django_db
|
def download_dir_content_mut() -> Path:
|
||||||
def download_dir_content(download_dir) -> Path:
|
tmpdir, tmpdir_o = _create_download_dir()
|
||||||
store = storage.Storage()
|
settings.DOWNLOAD_ROOT = tmpdir
|
||||||
|
_add_download_dir_content()
|
||||||
for video in Video.objects.filter(downloaded__isnull=False):
|
yield tmpdir
|
||||||
cf = store.get_channel_folder(video.channel.slug)
|
|
||||||
file_audio = cf.get_audio(video.slug)
|
|
||||||
file_tn = cf.get_thumbnail(video.slug)
|
|
||||||
file_cover = cf.get_cover(video.slug)
|
|
||||||
|
|
||||||
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", file_audio)
|
|
||||||
shutil.copyfile(tests.DIR_TESTFILES / "thumbnail" / f"{video.id}.webp", file_tn)
|
|
||||||
util.resize_thumbnail(file_tn, cf.get_thumbnail(video.slug, True))
|
|
||||||
cover.create_cover_file(
|
|
||||||
file_tn,
|
|
||||||
cf.file_avatar,
|
|
||||||
video.title,
|
|
||||||
video.channel.name,
|
|
||||||
cover.COVER_STYLE_BLUR,
|
|
||||||
file_cover,
|
|
||||||
)
|
|
||||||
videoutil.tag_audio(file_audio, video, file_cover)
|
|
||||||
|
|
||||||
yield download_dir
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@ -104,3 +149,13 @@ def mock_get_channel_metadata(mocker) -> mock.Mock:
|
||||||
channel_meta_mock: mock.Mock = mocker.patch.object(youtube, "get_channel_metadata")
|
channel_meta_mock: mock.Mock = mocker.patch.object(youtube, "get_channel_metadata")
|
||||||
channel_meta_mock.side_effect = tests.get_channel_metadata
|
channel_meta_mock.side_effect = tests.get_channel_metadata
|
||||||
return channel_meta_mock
|
return channel_meta_mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_admin(db):
|
||||||
|
return User.objects.get(id=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed_key(user_admin) -> str:
|
||||||
|
return user_admin.get_feed_key()
|
||||||
|
|
|
@ -11,8 +11,8 @@ def test_create_channel_folders(settings):
|
||||||
settings.DOWNLOAD_ROOT = tmpdir
|
settings.DOWNLOAD_ROOT = tmpdir
|
||||||
|
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
cf1 = store.get_channel_folder("ThetaDev")
|
cf1 = store.get_or_create_channel_folder("ThetaDev")
|
||||||
cf2 = store.get_channel_folder("Jeff_Geerling")
|
cf2 = store.get_or_create_channel_folder("Jeff_Geerling")
|
||||||
cf1b = store.get_channel_folder("ThetaDev")
|
cf1b = store.get_channel_folder("ThetaDev")
|
||||||
|
|
||||||
cf1_path = tmpdir / "ThetaDev"
|
cf1_path = tmpdir / "ThetaDev"
|
||||||
|
|
|
@ -90,3 +90,23 @@ def test_resize_thumbnail():
|
||||||
def test_slug(text: str, expected_slug: str):
|
def test_slug(text: str, expected_slug: str):
|
||||||
slug = util.get_slug(text)
|
slug = util.get_slug(text)
|
||||||
assert slug == expected_slug
|
assert slug == expected_slug
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url,expect",
|
||||||
|
[
|
||||||
|
("/files/audio/My_Video.mp3", "/files/audio/My_Video.mp3?key=my-key"),
|
||||||
|
(
|
||||||
|
"https://example.com/files/audio/My_Video.mp3",
|
||||||
|
"https://example.com/files/audio/My_Video.mp3?key=my-key",
|
||||||
|
),
|
||||||
|
("/files/avatar/ThetaDev.webp?sm", "/files/avatar/ThetaDev.webp?sm&key=my-key"),
|
||||||
|
(
|
||||||
|
"https://example.com/files/avatar/ThetaDev.webp?sm",
|
||||||
|
"https://example.com/files/avatar/ThetaDev.webp?sm&key=my-key",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_add_key_to_url(url: str, expect: str):
|
||||||
|
url_w_key = util.add_key_to_url(url, "my-key")
|
||||||
|
assert url_w_key == expect
|
||||||
|
|
|
@ -14,7 +14,7 @@ from ucast.service import videoutil
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_tag_audio():
|
def test_tag_audio():
|
||||||
video = Video.objects.get(id="ZPxEr4YdWt8")
|
video = Video.objects.get(video_id="ZPxEr4YdWt8")
|
||||||
|
|
||||||
tmpdir_o = tempfile.TemporaryDirectory()
|
tmpdir_o = tempfile.TemporaryDirectory()
|
||||||
tmpdir = Path(tmpdir_o.name)
|
tmpdir = Path(tmpdir_o.name)
|
||||||
|
@ -22,7 +22,14 @@ def test_tag_audio():
|
||||||
cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png"
|
cover_file = tests.DIR_TESTFILES / "cover" / "c1_blur.png"
|
||||||
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file)
|
shutil.copyfile(tests.DIR_TESTFILES / "audio" / "audio1.mp3", audio_file)
|
||||||
|
|
||||||
videoutil.tag_audio(audio_file, video, cover_file)
|
videoutil.tag_audio(
|
||||||
|
audio_file,
|
||||||
|
video.title,
|
||||||
|
video.channel.name,
|
||||||
|
video.published.date(),
|
||||||
|
video.get_full_description(),
|
||||||
|
cover_file,
|
||||||
|
)
|
||||||
|
|
||||||
tag = id3.ID3(audio_file)
|
tag = id3.ID3(audio_file)
|
||||||
assert tag["TPE1"].text[0] == "ThetaDev"
|
assert tag["TPE1"].text[0] == "ThetaDev"
|
||||||
|
|
|
@ -169,6 +169,6 @@ def test_get_channel_videos_from_scraper():
|
||||||
videos = youtube.get_channel_videos_from_scraper(CHANNEL_ID_THETADEV)
|
videos = youtube.get_channel_videos_from_scraper(CHANNEL_ID_THETADEV)
|
||||||
assert videos
|
assert videos
|
||||||
|
|
||||||
v1 = videos[0]
|
v1 = videos.__next__()
|
||||||
assert len(v1.id) == 11
|
assert len(v1.id) == 11
|
||||||
assert v1.published is None
|
assert v1.published is None
|
||||||
|
|
|
@ -14,11 +14,11 @@ VIDEO_SLUG_INTRO = "20211010_No_copyright_intro_free_fire_intro_no_text_free_cop
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_download_video(download_dir, rq_queue):
|
def test_download_video(download_dir, rq_queue):
|
||||||
video = Video.objects.get(id=VIDEO_ID_INTRO)
|
video = Video.objects.get(video_id=VIDEO_ID_INTRO)
|
||||||
job = queue.enqueue(download.download_video, video)
|
job = queue.enqueue(download.download_video, video)
|
||||||
|
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
cf = store.get_channel_folder(video.channel.slug)
|
cf = store.get_or_create_channel_folder(video.channel.slug)
|
||||||
|
|
||||||
assert job.is_finished
|
assert job.is_finished
|
||||||
|
|
||||||
|
@ -33,8 +33,8 @@ def test_import_channel(
|
||||||
download_dir, rq_queue, mock_get_video_details, mock_download_audio
|
download_dir, rq_queue, mock_get_video_details, mock_download_audio
|
||||||
):
|
):
|
||||||
# Remove 2 videos from the database so they can be imported
|
# Remove 2 videos from the database so they can be imported
|
||||||
Video.objects.get(id="ZPxEr4YdWt8").delete()
|
Video.objects.get(video_id="ZPxEr4YdWt8").delete()
|
||||||
Video.objects.get(id="_I5IFObm_-k").delete()
|
Video.objects.get(video_id="_I5IFObm_-k").delete()
|
||||||
|
|
||||||
job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV)
|
job = rq_queue.enqueue(download.import_channel, CHANNEL_ID_THETADEV)
|
||||||
assert job.is_finished
|
assert job.is_finished
|
||||||
|
@ -54,10 +54,10 @@ 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
|
||||||
):
|
):
|
||||||
# Remove 2 videos from the database so they can be imported
|
# Remove 2 videos from the database so they can be imported
|
||||||
Video.objects.get(id="ZPxEr4YdWt8").delete()
|
Video.objects.get(video_id="ZPxEr4YdWt8").delete()
|
||||||
Video.objects.get(id="_I5IFObm_-k").delete()
|
Video.objects.get(video_id="_I5IFObm_-k").delete()
|
||||||
|
|
||||||
channel = Channel.objects.get(id=CHANNEL_ID_THETADEV)
|
channel = Channel.objects.get(channel_id=CHANNEL_ID_THETADEV)
|
||||||
job = rq_queue.enqueue(download.update_channel, channel)
|
job = rq_queue.enqueue(download.update_channel, channel)
|
||||||
assert job.is_finished
|
assert job.is_finished
|
||||||
|
|
||||||
|
|
|
@ -11,13 +11,13 @@ CHANNEL_ID_THETADEV = "UCGiJh0NZ52wRhYKYnuZI08Q"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_recreate_cover(download_dir_content, rq_queue, mocker):
|
def test_recreate_cover(download_dir_content_mut, rq_queue, mocker):
|
||||||
create_cover_mock: mock.Mock = mocker.patch.object(cover, "create_cover_file")
|
create_cover_mock: mock.Mock = mocker.patch.object(cover, "create_cover_file")
|
||||||
|
|
||||||
video = Video.objects.get(id="ZPxEr4YdWt8")
|
video = Video.objects.get(video_id="ZPxEr4YdWt8")
|
||||||
|
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
cf = store.get_channel_folder(video.channel.slug)
|
cf = store.get_or_create_channel_folder(video.channel.slug)
|
||||||
|
|
||||||
job = rq_queue.enqueue(library.recreate_cover, video)
|
job = rq_queue.enqueue(library.recreate_cover, video)
|
||||||
assert job.is_finished
|
assert job.is_finished
|
||||||
|
@ -45,20 +45,29 @@ def test_recreate_covers(rq_queue, mocker):
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_update_channel_info(rq_queue, mock_get_channel_metadata):
|
def test_update_channel_info(rq_queue, mock_get_channel_metadata):
|
||||||
channel = Channel.objects.get(id=CHANNEL_ID_THETADEV)
|
channel = Channel.objects.get(channel_id=CHANNEL_ID_THETADEV)
|
||||||
|
|
||||||
|
channel.name = "Old name"
|
||||||
channel.description = "Old description"
|
channel.description = "Old description"
|
||||||
|
channel.subscribers = "Old"
|
||||||
|
channel.avatar_url = "Old avatar url"
|
||||||
channel.save()
|
channel.save()
|
||||||
|
|
||||||
job = rq_queue.enqueue(library.update_channel_info, channel)
|
job = rq_queue.enqueue(library.update_channel_info, channel)
|
||||||
assert job.is_finished
|
assert job.is_finished
|
||||||
|
|
||||||
channel.refresh_from_db()
|
channel.refresh_from_db()
|
||||||
|
assert channel.name == "ThetaDev"
|
||||||
assert (
|
assert (
|
||||||
channel.description
|
channel.description
|
||||||
== "I'm ThetaDev. I love creating cool projects \
|
== "I'm ThetaDev. I love creating cool projects \
|
||||||
using electronics, 3D printers and other awesome tech-based stuff."
|
using electronics, 3D printers and other awesome tech-based stuff."
|
||||||
)
|
)
|
||||||
|
assert channel.subscribers == "37"
|
||||||
|
assert (
|
||||||
|
channel.avatar_url
|
||||||
|
== "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
85
ucast/tests/test_feed.py
Normal file
85
ucast/tests/test_feed.py
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import feedparser
|
||||||
|
import pytest
|
||||||
|
from django.test.client import Client
|
||||||
|
from django.utils.feedgenerator import rfc2822_date
|
||||||
|
|
||||||
|
from ucast import feed
|
||||||
|
from ucast.models import Video
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_feed(feed_key):
|
||||||
|
client = Client()
|
||||||
|
response = client.get(f"/feed/ThetaDev?key={feed_key}")
|
||||||
|
parsed_feed = feedparser.parse(response.getvalue())
|
||||||
|
|
||||||
|
assert parsed_feed["bozo"] is False
|
||||||
|
|
||||||
|
assert parsed_feed["namespaces"] == {
|
||||||
|
"": "http://www.w3.org/2005/Atom",
|
||||||
|
"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = parsed_feed["feed"]
|
||||||
|
assert meta["title"] == "ThetaDev"
|
||||||
|
assert meta["link"] == "https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q"
|
||||||
|
assert (
|
||||||
|
meta["subtitle"]
|
||||||
|
== "I'm ThetaDev. I love creating cool projects using electronics, 3D printers and other awesome tech-based stuff."
|
||||||
|
)
|
||||||
|
assert meta["updated"] == "Sun, 15 May 2022 22:16:25 +0000"
|
||||||
|
assert (
|
||||||
|
meta["image"]["href"]
|
||||||
|
== f"http://testserver/files/avatar/ThetaDev.jpg?key={feed_key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(parsed_feed["entries"]) == 4
|
||||||
|
|
||||||
|
video_ids = ["ZPxEr4YdWt8", "_I5IFObm_-k", "mmEDPbbSnaY", "Cda4zS-1j-k"]
|
||||||
|
|
||||||
|
for i, entry in enumerate(parsed_feed["entries"]):
|
||||||
|
video = Video.objects.get(video_id=video_ids[i])
|
||||||
|
assert entry["title"] == video.title
|
||||||
|
assert entry["link"] == video.get_absolute_url()
|
||||||
|
assert entry["summary"] == feed.PodcastFeedType._xml_escape(
|
||||||
|
video.description
|
||||||
|
).replace("<br>", "<br />")
|
||||||
|
assert entry["published"] == rfc2822_date(video.published)
|
||||||
|
assert entry["id"] == video.get_absolute_url()
|
||||||
|
assert (
|
||||||
|
entry["image"]["href"]
|
||||||
|
== f"http://testserver/files/cover/ThetaDev/{video.slug}.png?key={feed_key}"
|
||||||
|
)
|
||||||
|
assert entry["itunes_duration"] == feed.PodcastFeedType._format_secs(
|
||||||
|
video.duration
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text,expect",
|
||||||
|
[
|
||||||
|
("Hello <World>", "Hello <World>"),
|
||||||
|
(
|
||||||
|
"go to https://example.org/test",
|
||||||
|
'go to <a href="https://example.org/test">https://example.org/test</a>',
|
||||||
|
),
|
||||||
|
("line1\nline2\nline3", "line1<br>line2<br>line3"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_xml_escape(text: str, expect: str):
|
||||||
|
escaped = feed.PodcastFeedType._xml_escape(text)
|
||||||
|
assert escaped == expect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"secs,expect",
|
||||||
|
[
|
||||||
|
(0, "00:00:00"),
|
||||||
|
(16, "00:00:16"),
|
||||||
|
(100, "00:01:40"),
|
||||||
|
(3800, "01:03:20"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_format_secs(secs: int, expect: str):
|
||||||
|
time_str = feed.PodcastFeedType._format_secs(secs)
|
||||||
|
assert time_str == expect
|
106
ucast/tests/test_views.py
Normal file
106
ucast/tests/test_views.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test.client import Client
|
||||||
|
|
||||||
|
from ucast.service import util
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url_path,internal_path",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"audio/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
|
||||||
|
"/internal_files/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cover/ThetaDev/20190602_ThetaDev_Embedded_World_2019.png",
|
||||||
|
"/internal_files/ThetaDev/_ucast/covers/20190602_ThetaDev_Embedded_World_2019.png",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp",
|
||||||
|
"/internal_files/ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019.webp",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp?sm",
|
||||||
|
"/internal_files/ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019_sm.webp",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"avatar/ThetaDev.jpg",
|
||||||
|
"/internal_files/ThetaDev/_ucast/avatar.jpg",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"avatar/ThetaDev.webp?sm",
|
||||||
|
"/internal_files/ThetaDev/_ucast/avatar_sm.webp",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_files_internal_redirect(
|
||||||
|
url_path: str, internal_path: str, download_dir_content, feed_key, admin_client
|
||||||
|
):
|
||||||
|
settings.INTERNAL_REDIRECT_HEADER = "X-Accel-Redirect"
|
||||||
|
|
||||||
|
def check_response(resp):
|
||||||
|
assert resp.getvalue() == b""
|
||||||
|
assert resp.headers.get("X-Accel-Redirect") == internal_path
|
||||||
|
assert "Content-Type" not in resp.headers
|
||||||
|
|
||||||
|
# Access with key
|
||||||
|
client = Client()
|
||||||
|
url = util.add_key_to_url("/files/" + url_path, feed_key)
|
||||||
|
response = client.get(url)
|
||||||
|
check_response(response)
|
||||||
|
|
||||||
|
# Access with login
|
||||||
|
response = admin_client.get("/files/" + url_path)
|
||||||
|
check_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"url_path,file_path",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"audio/ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
|
||||||
|
"ThetaDev/20190602_ThetaDev_Embedded_World_2019.mp3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cover/ThetaDev/20190602_ThetaDev_Embedded_World_2019.png",
|
||||||
|
"ThetaDev/_ucast/covers/20190602_ThetaDev_Embedded_World_2019.png",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp",
|
||||||
|
"ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019.webp",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thumbnail/ThetaDev/20190602_ThetaDev_Embedded_World_2019.webp?sm",
|
||||||
|
"ThetaDev/_ucast/thumbnails/20190602_ThetaDev_Embedded_World_2019_sm.webp",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"avatar/ThetaDev.jpg",
|
||||||
|
"ThetaDev/_ucast/avatar.jpg",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"avatar/ThetaDev.webp?sm",
|
||||||
|
"ThetaDev/_ucast/avatar_sm.webp",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_files_response(
|
||||||
|
url_path: str, file_path: str, download_dir_content, feed_key, admin_client
|
||||||
|
):
|
||||||
|
settings.INTERNAL_REDIRECT_HEADER = ""
|
||||||
|
|
||||||
|
response_file = settings.DOWNLOAD_ROOT / file_path
|
||||||
|
with open(response_file, "rb") as f:
|
||||||
|
file_bts = f.read()
|
||||||
|
|
||||||
|
# Access with key
|
||||||
|
client = Client()
|
||||||
|
url = util.add_key_to_url("/files/" + url_path, feed_key)
|
||||||
|
response = client.get(url)
|
||||||
|
response_bts = response.getvalue()
|
||||||
|
assert response_bts == file_bts
|
||||||
|
|
||||||
|
# Access with login
|
||||||
|
response = admin_client.get("/files/" + url_path)
|
||||||
|
response_bts = response.getvalue()
|
||||||
|
assert response_bts == file_bts
|
|
@ -2,4 +2,12 @@ from django.urls import path
|
||||||
|
|
||||||
from ucast import views
|
from ucast import views
|
||||||
|
|
||||||
urlpatterns = [path("", views.home)]
|
urlpatterns = [
|
||||||
|
path("", views.home),
|
||||||
|
path("channel/<str:channel>", views.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),
|
||||||
|
path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail),
|
||||||
|
path("files/avatar/<str:channel>", views.avatar),
|
||||||
|
]
|
||||||
|
|
141
ucast/views.py
141
ucast/views.py
|
@ -1,6 +1,145 @@
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from django import http
|
from django import http
|
||||||
|
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.middleware.http import ConditionalGetMiddleware
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
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.tasks import download
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def home(request: http.HttpRequest):
|
def home(request: http.HttpRequest):
|
||||||
return render(request, "ucast/main.html")
|
channels = Channel.objects.all()
|
||||||
|
|
||||||
|
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"ucast/channels.html",
|
||||||
|
{
|
||||||
|
"channels": channels,
|
||||||
|
"site_url": site_url,
|
||||||
|
"add_channel_form": forms.AddChannelForm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def videos(request: http.HttpRequest, channel: str):
|
||||||
|
chan = Channel.objects.get(slug=channel)
|
||||||
|
vids = Video.objects.filter(channel=chan).order_by("-published")
|
||||||
|
|
||||||
|
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"ucast/videos.html",
|
||||||
|
{"videos": vids, "channel": chan, "site_url": site_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
|
||||||
|
store = storage.Storage()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cf = store.get_channel_folder(channel)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise http.Http404
|
||||||
|
|
||||||
|
file_path = get_file(cf)
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
raise http.Http404
|
||||||
|
|
||||||
|
if not settings.INTERNAL_REDIRECT_HEADER:
|
||||||
|
return http.FileResponse(open(file_path, "rb"))
|
||||||
|
|
||||||
|
file_path_rel = file_path.relative_to(store.dir_data)
|
||||||
|
url_path_internal = f"/{settings.INTERNAL_FILES_ROOT}/{file_path_rel.as_posix()}"
|
||||||
|
|
||||||
|
response = http.HttpResponse()
|
||||||
|
response.headers[settings.INTERNAL_REDIRECT_HEADER] = url_path_internal
|
||||||
|
# Content type is set to text/html by default and has to be unset
|
||||||
|
del response.headers["Content-Type"]
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def login_or_key_required(function):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
|
key = request.GET.get("key")
|
||||||
|
if key:
|
||||||
|
try:
|
||||||
|
request.user = User.objects.get(feed_key=key)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return http.HttpResponse("401 Unauthorized", status=401)
|
||||||
|
|
||||||
|
return _wrapped_view
|
||||||
|
|
||||||
|
return decorator(function)
|
||||||
|
|
||||||
|
|
||||||
|
@login_or_key_required
|
||||||
|
def audio(request: http.HttpRequest, channel: str, video: str):
|
||||||
|
# Trim off file extension
|
||||||
|
video_slug = video.rsplit(".")[0]
|
||||||
|
|
||||||
|
return _channel_file(channel, lambda cf: cf.get_audio(video_slug))
|
||||||
|
|
||||||
|
|
||||||
|
@login_or_key_required
|
||||||
|
def cover(request: http.HttpRequest, channel: str, video: str):
|
||||||
|
# Trim off file extension
|
||||||
|
video_slug = video.rsplit(".")[0]
|
||||||
|
|
||||||
|
return _channel_file(channel, lambda cf: cf.get_cover(video_slug))
|
||||||
|
|
||||||
|
|
||||||
|
@login_or_key_required
|
||||||
|
def thumbnail(request: http.HttpRequest, channel: str, video: str):
|
||||||
|
# Trim off file extension
|
||||||
|
video_slug = video.rsplit(".")[0]
|
||||||
|
|
||||||
|
is_sm = "sm" in request.GET
|
||||||
|
|
||||||
|
return _channel_file(channel, lambda cf: cf.get_thumbnail(video_slug, is_sm))
|
||||||
|
|
||||||
|
|
||||||
|
@login_or_key_required
|
||||||
|
def avatar(request: http.HttpRequest, channel: str):
|
||||||
|
# Trim off file extension
|
||||||
|
channel_slug = channel.rsplit(".")[0]
|
||||||
|
|
||||||
|
is_sm = "sm" in request.GET
|
||||||
|
|
||||||
|
if is_sm:
|
||||||
|
return _channel_file(channel_slug, lambda cf: cf.file_avatar_sm)
|
||||||
|
return _channel_file(channel_slug, lambda cf: cf.file_avatar)
|
||||||
|
|
||||||
|
|
||||||
|
@login_or_key_required
|
||||||
|
@decorator_from_middleware(ConditionalGetMiddleware)
|
||||||
|
def podcast_feed(request: http.HttpRequest, *args, **kwargs):
|
||||||
|
return feed.UcastFeed()(request, *args, **kwargs)
|
||||||
|
|
|
@ -36,6 +36,13 @@ def get_env_path(name, default=None):
|
||||||
return Path(raw_env).absolute()
|
return Path(raw_env).absolute()
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_list(name):
|
||||||
|
raw_env = get_env(name)
|
||||||
|
if not raw_env:
|
||||||
|
return []
|
||||||
|
return [i.strip() for i in raw_env.split(",")]
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv() -> Path:
|
def _load_dotenv() -> Path:
|
||||||
"""
|
"""
|
||||||
Look for a .env file in the current working directory or
|
Look for a .env file in the current working directory or
|
||||||
|
@ -78,7 +85,7 @@ SECRET_KEY = get_env(
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = get_env("DEBUG", False)
|
DEBUG = get_env("DEBUG", False)
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = get_env_list("ALLOWED_HOSTS")
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
|
@ -176,6 +183,11 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "ucast.user"
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
||||||
|
|
||||||
|
@ -206,3 +218,6 @@ 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)
|
||||||
|
|
||||||
|
INTERNAL_FILES_ROOT = get_env("INTERNAL_FILES_ROOT", "internal_files")
|
||||||
|
INTERNAL_REDIRECT_HEADER = get_env("INTERNAL_REDIRECT_HEADER", "X-Accel-Redirect")
|
||||||
|
|
|
@ -18,5 +18,6 @@ from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
path("", include("ucast.urls")),
|
path("", include("ucast.urls")),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in a new issue