Compare commits
2 commits
caefa4dd37
...
ede37ebe17
Author | SHA1 | Date | |
---|---|---|---|
ede37ebe17 | |||
0d34a96227 |
16 changed files with 178 additions and 70 deletions
|
@ -1,5 +1,5 @@
|
||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.1
|
current_version = 0.2.0
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,8 @@ FROM python:3.10
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
COPY --from=0 /build/dist /install
|
COPY --from=0 /build/dist /install
|
||||||
RUN pip install -- /install/*.whl gunicorn honcho
|
RUN pip install -- /install/*.whl gunicorn honcho && \
|
||||||
|
rm -rf ~/.cache/pip
|
||||||
|
|
||||||
# ffmpeg static source (https://johnvansickle.com/ffmpeg/)
|
# ffmpeg static source (https://johnvansickle.com/ffmpeg/)
|
||||||
RUN set -e; \
|
RUN set -e; \
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "ucast"
|
name = "ucast"
|
||||||
version = "0.1.1"
|
version = "0.2.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.1.1"
|
__version__ = "0.2.0"
|
||||||
|
|
||||||
|
|
||||||
def template_context(request):
|
def template_context(request):
|
||||||
|
|
|
@ -4,6 +4,7 @@ import rq_scheduler
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rq import registry
|
from rq import registry
|
||||||
|
|
||||||
|
from ucast.models import Video
|
||||||
from ucast.service import util
|
from ucast.service import util
|
||||||
|
|
||||||
|
|
||||||
|
@ -33,8 +34,7 @@ def get_worker(**kwargs) -> rq.Worker:
|
||||||
|
|
||||||
def enqueue(f, *args, **kwargs) -> rq.job.Job:
|
def enqueue(f, *args, **kwargs) -> rq.job.Job:
|
||||||
queue = get_queue()
|
queue = get_queue()
|
||||||
# return queue.enqueue(f, *args, **kwargs)
|
return queue.enqueue(f, *args, **kwargs)
|
||||||
return queue.enqueue_call(f, args, kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def get_statistics() -> dict:
|
def get_statistics() -> dict:
|
||||||
|
@ -90,3 +90,19 @@ def get_statistics() -> dict:
|
||||||
def get_failed_job_registry():
|
def get_failed_job_registry():
|
||||||
queue = get_queue()
|
queue = get_queue()
|
||||||
return registry.FailedJobRegistry(queue.name, queue.connection)
|
return registry.FailedJobRegistry(queue.name, queue.connection)
|
||||||
|
|
||||||
|
|
||||||
|
def get_downloading_videos():
|
||||||
|
queue = get_queue()
|
||||||
|
videos = {}
|
||||||
|
|
||||||
|
for job in queue.jobs:
|
||||||
|
if (
|
||||||
|
job.func_name == "ucast.tasks.download.download_video"
|
||||||
|
and job.args
|
||||||
|
and isinstance(job.args[0], Video)
|
||||||
|
):
|
||||||
|
video = job.args[0]
|
||||||
|
videos[video.id] = video
|
||||||
|
|
||||||
|
return list(videos.values())
|
||||||
|
|
|
@ -125,7 +125,19 @@ def download_thumbnail(vinfo: VideoDetails, download_path: Path):
|
||||||
|
|
||||||
|
|
||||||
def get_video_details(video_id: str) -> VideoDetails:
|
def get_video_details(video_id: str) -> VideoDetails:
|
||||||
with YoutubeDL() as ydl:
|
"""
|
||||||
|
Get the details of a YouTube video without downloading it.
|
||||||
|
|
||||||
|
:param video_id: YouTube video ID
|
||||||
|
:return: VideoDetails
|
||||||
|
"""
|
||||||
|
cache = storage.Cache()
|
||||||
|
|
||||||
|
ydl_params = {
|
||||||
|
"cachedir": str(cache.dir_ytdlp_cache),
|
||||||
|
}
|
||||||
|
|
||||||
|
with YoutubeDL(ydl_params) as ydl:
|
||||||
info = ydl.extract_info(video_id, download=False)
|
info = ydl.extract_info(video_id, download=False)
|
||||||
return VideoDetails.from_vinfo(info)
|
return VideoDetails.from_vinfo(info)
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,13 @@ from ucast.service import controller, cover, storage, util, videoutil, youtube
|
||||||
|
|
||||||
|
|
||||||
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
||||||
|
# Use Redis to ensure the same video is not processed multiple times
|
||||||
|
redis = queue.get_redis_connection()
|
||||||
|
lock_key = f"ucast:lock_load_video:{vid.id}"
|
||||||
|
|
||||||
|
if not redis.set(lock_key, "1", 120, nx=True):
|
||||||
|
return
|
||||||
|
|
||||||
# Create video object if it does not exist
|
# Create video object if it does not exist
|
||||||
try:
|
try:
|
||||||
video = Video.objects.get(video_id=vid.id)
|
video = Video.objects.get(video_id=vid.id)
|
||||||
|
@ -18,6 +25,7 @@ def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
||||||
|
|
||||||
# Dont load active livestreams
|
# Dont load active livestreams
|
||||||
if details.is_currently_live:
|
if details.is_currently_live:
|
||||||
|
redis.delete(lock_key)
|
||||||
return
|
return
|
||||||
|
|
||||||
slug = Video.get_new_slug(
|
slug = Video.get_new_slug(
|
||||||
|
@ -44,6 +52,8 @@ def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel):
|
||||||
):
|
):
|
||||||
queue.enqueue(download_video, video)
|
queue.enqueue(download_video, video)
|
||||||
|
|
||||||
|
redis.delete(lock_key)
|
||||||
|
|
||||||
|
|
||||||
def download_video(video: Video):
|
def download_video(video: Video):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -27,8 +27,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<a class="navbar-item" href="{% url 'download_errors' %}">
|
<a class="navbar-item" href="{% url 'downloads' %}">
|
||||||
Errors
|
Downloads
|
||||||
</a>
|
</a>
|
||||||
{% url 'login' as login_url %}
|
{% url 'login' as login_url %}
|
||||||
{% url 'logout' as logout_url %}
|
{% url 'logout' as logout_url %}
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
{% extends 'base.html' %}
|
|
||||||
|
|
||||||
{% block title %}ucast - Errors{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<div>
|
|
||||||
<span class="title">Download errors</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if jobs %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<form method="post" action="{% url 'download_errors_requeue_all' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button class="button is-primary">Requeue all</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Function</th>
|
|
||||||
<th>Details</th>
|
|
||||||
<th>Requeue</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for job in jobs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ job.id }}</td>
|
|
||||||
<td>{{ job.func_name }}</td>
|
|
||||||
<td><a href="{% url 'error_details' job.id %}">Details</a></td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="{% url 'download_errors_requeue' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="id" value="{{ job.id }}">
|
|
||||||
<button class="button is-small">Requeue</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p>No download errors</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
81
ucast/templates/ucast/downloads.html
Normal file
81
ucast/templates/ucast/downloads.html
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}ucast - Errors{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="title">Downloading
|
||||||
|
{% if downloading_videos %}({{ downloading_videos.paginator.count }}){% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if downloading_videos %}
|
||||||
|
<table class="table">
|
||||||
|
<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" %}
|
||||||
|
{% else %}
|
||||||
|
<p>Not downloading any videos</p>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="title">Download errors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if failed_jobs %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<form method="post" action="{% url 'download_errors_requeue_all' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="button is-primary">Requeue all</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Function</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Requeue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in failed_jobs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ job.id }}</td>
|
||||||
|
<td>{{ job.func_name }}</td>
|
||||||
|
<td><a href="{% url 'error_details' job.id %}">Details</a></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{% url 'download_errors_requeue' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="id" value="{{ job.id }}">
|
||||||
|
<button class="button is-small">Requeue</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No download errors</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock content %}
|
13
ucast/templates/ucast/downloads_items.html
Normal file
13
ucast/templates/ucast/downloads_items.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{% for video in downloading_videos %}
|
||||||
|
<tr {% if forloop.last and downloading_videos.has_next %}
|
||||||
|
hx-get="?page={{ downloading_videos.next_page_number }}"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="afterend"
|
||||||
|
{% endif %}>
|
||||||
|
<td><a href="{{ video.get_absolute_url }}">{{ video.video_id }}</a></td>
|
||||||
|
<td>{{ video.title }}</td>
|
||||||
|
<td><a href="{% url 'videos' video.channel.slug %}">{{ video.channel.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ video.published|date:"SHORT_DATE_FORMAT" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
|
@ -13,7 +13,7 @@
|
||||||
<span class="tag"><i
|
<span class="tag"><i
|
||||||
class="fas fa-user-group"></i> {{ channel.subscribers }}</span>
|
class="fas fa-user-group"></i> {{ channel.subscribers }}</span>
|
||||||
<span class="tag"><i
|
<span class="tag"><i
|
||||||
class="fas fa-video"></i> {{ videos|length }}</span>
|
class="fas fa-video"></i> {{ videos.paginator.count }}</span>
|
||||||
<span class="tag"><i
|
<span class="tag"><i
|
||||||
class="fas fa-database"></i> {{ channel.download_size|filesizeformat }}</span>
|
class="fas fa-database"></i> {{ channel.download_size|filesizeformat }}</span>
|
||||||
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
|
<a class="tag" href="{{ channel.get_absolute_url }}" target="_blank"><i
|
||||||
|
|
|
@ -116,6 +116,14 @@ def download_dir_content_mut() -> Path:
|
||||||
yield tmpdir
|
yield tmpdir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_redis(mocker) -> FakeRedis:
|
||||||
|
redis = FakeRedis()
|
||||||
|
mocker.patch.object(queue, "get_redis_connection")
|
||||||
|
queue.get_redis_connection.return_value = redis
|
||||||
|
return redis
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def rq_queue(mocker) -> rq.Queue:
|
def rq_queue(mocker) -> rq.Queue:
|
||||||
test_queue = rq.Queue(is_async=False, connection=FakeRedis())
|
test_queue = rq.Queue(is_async=False, connection=FakeRedis())
|
||||||
|
|
|
@ -30,7 +30,7 @@ def test_download_video(download_dir, rq_queue):
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_update_channel(
|
def test_update_channel(
|
||||||
download_dir, rq_queue, mock_get_video_details, mock_download_audio
|
download_dir, rq_queue, mock_redis, 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(video_id="ZPxEr4YdWt8").delete()
|
Video.objects.get(video_id="ZPxEr4YdWt8").delete()
|
||||||
|
|
|
@ -17,16 +17,18 @@ urlpatterns = [
|
||||||
views.channel_download,
|
views.channel_download,
|
||||||
name="channel_download",
|
name="channel_download",
|
||||||
),
|
),
|
||||||
path("errors", views.download_errors, name="download_errors"),
|
path("downloads", views.downloads, name="downloads"),
|
||||||
path(
|
path(
|
||||||
"errors/requeue", views.download_errors_requeue, name="download_errors_requeue"
|
"downloads/requeue",
|
||||||
|
views.download_errors_requeue,
|
||||||
|
name="download_errors_requeue",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"errors/requeue_all",
|
"downloads/requeue_all",
|
||||||
views.download_errors_requeue_all,
|
views.download_errors_requeue_all,
|
||||||
name="download_errors_requeue_all",
|
name="download_errors_requeue_all",
|
||||||
),
|
),
|
||||||
path("errors/<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("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),
|
||||||
|
|
|
@ -154,12 +154,27 @@ def channel_download(request: http.HttpRequest, channel: str):
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def download_errors(request: http.HttpRequest):
|
def downloads(request: http.HttpRequest):
|
||||||
freg = queue.get_failed_job_registry()
|
freg = queue.get_failed_job_registry()
|
||||||
ids = freg.get_job_ids(0, 50)
|
ids = freg.get_job_ids(0, 50)
|
||||||
jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
|
failed_jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
|
||||||
|
|
||||||
return render(request, "ucast/download_errors.html", {"jobs": jobs})
|
page_number = request.GET.get("page")
|
||||||
|
downloading_videos = queue.get_downloading_videos()
|
||||||
|
downloading_videos_p = Paginator(downloading_videos, 100)
|
||||||
|
|
||||||
|
template_name = "ucast/downloads.html"
|
||||||
|
if request.htmx or request.GET.get("items"):
|
||||||
|
template_name = "ucast/downloads_items.html"
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
template_name,
|
||||||
|
{
|
||||||
|
"failed_jobs": failed_jobs,
|
||||||
|
"downloading_videos": downloading_videos_p.get_page(page_number),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -178,7 +193,7 @@ def download_errors_requeue(request: http.HttpRequest):
|
||||||
freg = queue.get_failed_job_registry()
|
freg = queue.get_failed_job_registry()
|
||||||
freg.requeue(str(form.cleaned_data["id"]))
|
freg.requeue(str(form.cleaned_data["id"]))
|
||||||
|
|
||||||
return http.HttpResponseRedirect(reverse(download_errors))
|
return http.HttpResponseRedirect(reverse(downloads))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -187,7 +202,7 @@ def download_errors_requeue_all(request: http.HttpRequest):
|
||||||
for job_id in freg.get_job_ids():
|
for job_id in freg.get_job_ids():
|
||||||
freg.requeue(job_id)
|
freg.requeue(job_id)
|
||||||
|
|
||||||
return http.HttpResponseRedirect(reverse(download_errors))
|
return http.HttpResponseRedirect(reverse(downloads))
|
||||||
|
|
||||||
|
|
||||||
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
|
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
|
||||||
|
|
Loading…
Reference in a new issue