Compare commits
5 commits
ede37ebe17
...
7c741a476f
Author | SHA1 | Date | |
---|---|---|---|
7c741a476f | |||
e5c1fbdfb4 | |||
df90e42729 | |||
f21387a23c | |||
ded1895adb |
18 changed files with 202 additions and 55 deletions
|
@ -1,5 +1,5 @@
|
||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.2.0
|
current_version = 0.3.0
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
|
|
||||||
|
|
|
@ -19,16 +19,16 @@
|
||||||
grid-column: auto
|
grid-column: auto
|
||||||
|
|
||||||
@include tablet
|
@include tablet
|
||||||
grid-template-columns: repeat(3,minmax(0,1fr))
|
grid-template-columns: repeat(3, minmax(0, 1fr))
|
||||||
|
|
||||||
@include desktop
|
@include desktop
|
||||||
grid-template-columns: repeat(4,minmax(0,1fr))
|
grid-template-columns: repeat(4, minmax(0, 1fr))
|
||||||
|
|
||||||
@include widescreen
|
@include widescreen
|
||||||
grid-template-columns: repeat(5,minmax(0,1fr))
|
grid-template-columns: repeat(5, minmax(0, 1fr))
|
||||||
|
|
||||||
@include fullhd
|
@include fullhd
|
||||||
grid-template-columns: repeat(6,minmax(0,1fr))
|
grid-template-columns: repeat(6, minmax(0, 1fr))
|
||||||
|
|
||||||
.video-card
|
.video-card
|
||||||
display: flex
|
display: flex
|
||||||
|
@ -43,3 +43,6 @@
|
||||||
// Fix almost invisible navbar items on mobile
|
// Fix almost invisible navbar items on mobile
|
||||||
.navbar-item
|
.navbar-item
|
||||||
color: #fff
|
color: #fff
|
||||||
|
|
||||||
|
.overflow-x
|
||||||
|
overflow-x: auto
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "ucast"
|
name = "ucast"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "YouTube to Podcast converter"
|
description = "YouTube to Podcast converter"
|
||||||
authors = ["Theta-Dev <t.testboy@gmail.com>"]
|
authors = ["Theta-Dev <t.testboy@gmail.com>"]
|
||||||
packages = [
|
packages = [
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
|
|
||||||
def template_context(request):
|
def template_context(request):
|
||||||
|
|
|
@ -147,6 +147,12 @@ class UcastFeed(Feed):
|
||||||
return Channel.objects.get(slug=channel_slug)
|
return Channel.objects.get(slug=channel_slug)
|
||||||
|
|
||||||
def get_feed(self, channel: Channel, request: http.HttpRequest):
|
def get_feed(self, channel: Channel, request: http.HttpRequest):
|
||||||
|
max_items = settings.FEED_MAX_ITEMS
|
||||||
|
try:
|
||||||
|
max_items = int(request.GET.get("items"))
|
||||||
|
except TypeError or ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
feed = self.feed_type(
|
feed = self.feed_type(
|
||||||
title=channel.name,
|
title=channel.name,
|
||||||
link=channel.get_absolute_url(),
|
link=channel.get_absolute_url(),
|
||||||
|
@ -158,7 +164,7 @@ class UcastFeed(Feed):
|
||||||
|
|
||||||
for video in channel.video_set.filter(downloaded__isnull=False).order_by(
|
for video in channel.video_set.filter(downloaded__isnull=False).order_by(
|
||||||
"-published"
|
"-published"
|
||||||
)[: settings.FEED_MAX_ITEMS]:
|
)[:max_items]:
|
||||||
feed.add_item(
|
feed.add_item(
|
||||||
title=video.title,
|
title=video.title,
|
||||||
link=video.get_absolute_url(),
|
link=video.get_absolute_url(),
|
||||||
|
|
|
@ -92,11 +92,11 @@ def get_failed_job_registry():
|
||||||
return registry.FailedJobRegistry(queue.name, queue.connection)
|
return registry.FailedJobRegistry(queue.name, queue.connection)
|
||||||
|
|
||||||
|
|
||||||
def get_downloading_videos():
|
def get_downloading_videos(offset=0, limit=-1):
|
||||||
queue = get_queue()
|
queue = get_queue()
|
||||||
videos = {}
|
videos = {}
|
||||||
|
|
||||||
for job in queue.jobs:
|
for job in queue.get_jobs(offset, limit):
|
||||||
if (
|
if (
|
||||||
job.func_name == "ucast.tasks.download.download_video"
|
job.func_name == "ucast.tasks.download.download_video"
|
||||||
and job.args
|
and job.args
|
||||||
|
|
40
ucast/service/opml.py
Normal file
40
ucast/service/opml.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from django.utils.xmlutils import SimplerXMLGenerator
|
||||||
|
|
||||||
|
from ucast.models import Channel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeedElement:
|
||||||
|
url: str
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
def __add_feed_element(handler: SimplerXMLGenerator, element: FeedElement):
|
||||||
|
handler.addQuickElement(
|
||||||
|
"outline", attrs={"xmlUrl": element.url, "title": element.title}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_opml(elements: Iterable[FeedElement], outfile):
|
||||||
|
handler = SimplerXMLGenerator(outfile, "utf-8", short_empty_elements=True)
|
||||||
|
handler.startDocument()
|
||||||
|
handler.startElement("opml", {})
|
||||||
|
handler.addQuickElement("head")
|
||||||
|
handler.startElement("body", {"version": "1.0"})
|
||||||
|
|
||||||
|
for element in elements:
|
||||||
|
__add_feed_element(handler, element)
|
||||||
|
|
||||||
|
handler.endElement("body")
|
||||||
|
handler.endElement("opml")
|
||||||
|
handler.endDocument()
|
||||||
|
|
||||||
|
|
||||||
|
def write_channels_opml(channels: Iterable[Channel], site_url: str, key: str, outfile):
|
||||||
|
elements = [
|
||||||
|
FeedElement(f"{site_url}/feed/{c.slug}?key={key}", c.name) for c in channels
|
||||||
|
]
|
||||||
|
write_opml(elements, outfile)
|
|
@ -10363,4 +10363,8 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-x {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/*# sourceMappingURL=style.css.map */
|
/*# sourceMappingURL=style.css.map */
|
File diff suppressed because one or more lines are too long
2
ucast/static/bulma/css/style.min.css
vendored
2
ucast/static/bulma/css/style.min.css
vendored
File diff suppressed because one or more lines are too long
|
@ -27,6 +27,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
<a class="navbar-item" href="{% url 'search' %}">
|
||||||
|
Search
|
||||||
|
</a>
|
||||||
<a class="navbar-item" href="{% url 'downloads' %}">
|
<a class="navbar-item" href="{% url 'downloads' %}">
|
||||||
Downloads
|
Downloads
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -68,6 +68,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="{% url 'channels_opml' %}">Download OPML</a>
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block javascript %}
|
{% block javascript %}
|
||||||
|
|
|
@ -1,36 +1,23 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}ucast - Errors{% endblock %}
|
{% block title %}ucast - Downloads{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div>
|
<div>
|
||||||
<span class="title">Downloading
|
<span class="title">Downloading</span>
|
||||||
{% if downloading_videos %}({{ downloading_videos.paginator.count }}){% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
{% if downloading_videos %}
|
{% if downloading_videos %}
|
||||||
<table class="table">
|
<div class="mb-4" hx-get="{% url 'downloads' %}" hx-trigger="every 5s">
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Video-ID</th>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Channel</th>
|
|
||||||
<th>Published</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody hx-get="?items" hx-trigger="every 5s">
|
|
||||||
{% include "ucast/downloads_items.html" %}
|
{% include "ucast/downloads_items.html" %}
|
||||||
{% else %}
|
|
||||||
<p>Not downloading any videos</p>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<p>Not downloading any videos</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -1,13 +1,26 @@
|
||||||
{% for video in downloading_videos %}
|
<div class="mb-4">
|
||||||
<tr {% if forloop.last and downloading_videos.has_next %}
|
<a class="subtitle">{{ n_downloading_videos }} Videos</a>
|
||||||
hx-get="?page={{ downloading_videos.next_page_number }}"
|
</div>
|
||||||
hx-trigger="revealed"
|
<div class="mb-4 overflow-x">
|
||||||
hx-swap="afterend"
|
<table class="table">
|
||||||
{% endif %}>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Video-ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Published</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for video in downloading_videos %}
|
||||||
|
<tr>
|
||||||
<td><a href="{{ video.get_absolute_url }}">{{ video.video_id }}</a></td>
|
<td><a href="{{ video.get_absolute_url }}">{{ video.video_id }}</a></td>
|
||||||
<td>{{ video.title }}</td>
|
<td>{{ video.title }}</td>
|
||||||
<td><a href="{% url 'videos' video.channel.slug %}">{{ video.channel.name }}</a>
|
<td><a href="{% url 'videos' video.channel.slug %}">{{ video.channel.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ video.published|date:"SHORT_DATE_FORMAT" }}</td>
|
<td>{{ video.published|date:"SHORT_DATE_FORMAT" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
61
ucast/templates/ucast/search.html
Normal file
61
ucast/templates/ucast/search.html
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}ucast - Search{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="box">
|
||||||
|
<form method="get">
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control is-flex-grow-1">
|
||||||
|
<input name="q" required class="input" type="text"
|
||||||
|
placeholder="Search" {% if query %}value="{{ query }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-grid">
|
||||||
|
{% if videos %}
|
||||||
|
{% for video in videos %}
|
||||||
|
<div class="card video-card">
|
||||||
|
<a href="{{ video.get_absolute_url }}" target="_blank">
|
||||||
|
<img class="video-thumbnail"
|
||||||
|
src="/files/thumbnail/{{ video.channel.slug }}/{{ video.slug }}.webp?sm">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="video-card-content is-flex-grow-1">
|
||||||
|
<a href="{{ video.get_absolute_url }}">{{ video.title }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-card-content">
|
||||||
|
|
||||||
|
<div class="level">
|
||||||
|
<span class="tag">
|
||||||
|
<i
|
||||||
|
class="fas fa-calendar"></i> {{ video.published|date:"SHORT_DATE_FORMAT" }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<a class="button is-small is-success"
|
||||||
|
href="/files/audio/{{ video.channel.slug }}/{{ video.slug }}.mp3"
|
||||||
|
target="_blank">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif query %}
|
||||||
|
<p>No videos</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
|
@ -7,7 +7,7 @@
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
<a href="{{ video.get_absolute_url }}" target="_blank">
|
<a href="{{ video.get_absolute_url }}" target="_blank">
|
||||||
<img class="video-thumbnail"
|
<img class="video-thumbnail"
|
||||||
src="/files/thumbnail/{{ channel.slug }}/{{ video.slug }}.webp?sm">
|
src="/files/thumbnail/{{ video.channel.slug }}/{{ video.slug }}.webp?sm">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="video-card-content is-flex-grow-1">
|
<div class="video-card-content is-flex-grow-1">
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="button is-small is-success"
|
<a class="button is-small is-success"
|
||||||
href="/files/audio/{{ channel.slug }}/{{ video.slug }}.mp3"
|
href="/files/audio/{{ video.channel.slug }}/{{ video.slug }}.mp3"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -30,6 +30,8 @@ urlpatterns = [
|
||||||
),
|
),
|
||||||
path("downloads/error/<str:job_id>", views.error_details, name="error_details"),
|
path("downloads/error/<str:job_id>", views.error_details, name="error_details"),
|
||||||
path("feed/<str:channel>", views.podcast_feed, name="feed"),
|
path("feed/<str:channel>", views.podcast_feed, name="feed"),
|
||||||
|
path("opml", views.channels_opml, name="channels_opml"),
|
||||||
|
path("search", views.search, name="search"),
|
||||||
path("files/audio/<str:channel>/<str:video>", views.audio),
|
path("files/audio/<str:channel>/<str:video>", views.audio),
|
||||||
path("files/cover/<str:channel>/<str:video>", views.cover),
|
path("files/cover/<str:channel>/<str:video>", views.cover),
|
||||||
path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail),
|
path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail),
|
||||||
|
|
|
@ -16,7 +16,7 @@ from django.utils.decorators import decorator_from_middleware
|
||||||
|
|
||||||
from ucast import feed, forms, queue
|
from ucast import feed, forms, queue
|
||||||
from ucast.models import Channel, User, Video
|
from ucast.models import Channel, User, Video
|
||||||
from ucast.service import controller, storage
|
from ucast.service import controller, opml, storage
|
||||||
from ucast.tasks import download
|
from ucast.tasks import download
|
||||||
|
|
||||||
|
|
||||||
|
@ -159,12 +159,10 @@ def downloads(request: http.HttpRequest):
|
||||||
ids = freg.get_job_ids(0, 50)
|
ids = freg.get_job_ids(0, 50)
|
||||||
failed_jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
|
failed_jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
|
||||||
|
|
||||||
page_number = request.GET.get("page")
|
downloading_videos = queue.get_downloading_videos(limit=100)
|
||||||
downloading_videos = queue.get_downloading_videos()
|
|
||||||
downloading_videos_p = Paginator(downloading_videos, 100)
|
|
||||||
|
|
||||||
template_name = "ucast/downloads.html"
|
template_name = "ucast/downloads.html"
|
||||||
if request.htmx or request.GET.get("items"):
|
if request.htmx:
|
||||||
template_name = "ucast/downloads_items.html"
|
template_name = "ucast/downloads_items.html"
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
|
@ -172,7 +170,8 @@ def downloads(request: http.HttpRequest):
|
||||||
template_name,
|
template_name,
|
||||||
{
|
{
|
||||||
"failed_jobs": failed_jobs,
|
"failed_jobs": failed_jobs,
|
||||||
"downloading_videos": downloading_videos_p.get_page(page_number),
|
"downloading_videos": downloading_videos,
|
||||||
|
"n_downloading_videos": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -205,6 +204,31 @@ def download_errors_requeue_all(request: http.HttpRequest):
|
||||||
return http.HttpResponseRedirect(reverse(downloads))
|
return http.HttpResponseRedirect(reverse(downloads))
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def channels_opml(request: http.HttpRequest):
|
||||||
|
response = http.HttpResponse(
|
||||||
|
content_type="application/xml",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=ucast_channels.opml"},
|
||||||
|
)
|
||||||
|
site_url = add_domain(get_current_site(request).domain, "", request.is_secure())
|
||||||
|
opml.write_channels_opml(
|
||||||
|
Channel.objects.all(), site_url, request.user.get_feed_key(), response
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def search(request: http.HttpRequest):
|
||||||
|
query = request.GET.get("q")
|
||||||
|
vids = []
|
||||||
|
if query:
|
||||||
|
vids = Video.objects.filter(downloaded__isnull=False, title__icontains=query)[
|
||||||
|
:30
|
||||||
|
]
|
||||||
|
|
||||||
|
return render(request, "ucast/search.html", {"query": query, "videos": vids})
|
||||||
|
|
||||||
|
|
||||||
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
|
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
|
||||||
store = storage.Storage()
|
store = storage.Storage()
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue