Skip to content

Commit 6ead291

Browse files
authored
[Enhancement] Auto-update yt-dlp (#589)
* Added a command for updating yt-dlp * Added a yt-dlp update worker to run daily * Added a new file that runs post-boot when the app is ready to serve requests; put yt-dlp updater in there * Updated config to expose the current env globally; updated startup tasks to not run in test env * Removes unneeded test code
1 parent 62214b8 commit 6ead291

14 files changed

+202
-26
lines changed

config/config.exs

+2-10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Config
1010
config :pinchflat,
1111
ecto_repos: [Pinchflat.Repo],
1212
generators: [timestamp_type: :utc_datetime],
13+
env: config_env(),
1314
# Specifying backend data here makes mocking and local testing SUPER easy
1415
yt_dlp_executable: System.find_executable("yt-dlp"),
1516
apprise_executable: System.find_executable("apprise"),
@@ -49,16 +50,7 @@ config :pinchflat, PinchflatWeb.Endpoint,
4950

5051
config :pinchflat, Oban,
5152
engine: Oban.Engines.Lite,
52-
repo: Pinchflat.Repo,
53-
# Keep old jobs for 30 days for display in the UI
54-
plugins: [
55-
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
56-
{Oban.Plugins.Cron,
57-
crontab: [
58-
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
59-
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
60-
]}
61-
]
53+
repo: Pinchflat.Repo
6254

6355
# Configures the mailer
6456
#

config/runtime.exs

+15
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ config :pinchflat, Pinchflat.Repo,
4343
# Some users may want to increase the number of workers that use yt-dlp to improve speeds
4444
# Others may want to decrease the number of these workers to lessen the chance of an IP ban
4545
{yt_dlp_worker_count, _} = Integer.parse(System.get_env("YT_DLP_WORKER_CONCURRENCY", "2"))
46+
# Used to set the cron for the yt-dlp update worker. The reason for this is
47+
# to avoid all instances of PF updating yt-dlp at the same time, which 1)
48+
# could result in rate limiting and 2) gives me time to react if an update
49+
# breaks something
50+
%{hour: current_hour, minute: current_minute} = DateTime.utc_now()
4651

4752
config :pinchflat, Oban,
4853
queues: [
@@ -52,6 +57,16 @@ config :pinchflat, Oban,
5257
media_fetching: yt_dlp_worker_count,
5358
remote_metadata: yt_dlp_worker_count,
5459
local_data: 8
60+
],
61+
plugins: [
62+
# Keep old jobs for 30 days for display in the UI
63+
{Oban.Plugins.Pruner, max_age: 30 * 24 * 60 * 60},
64+
{Oban.Plugins.Cron,
65+
crontab: [
66+
{"#{current_minute} #{current_hour} * * *", Pinchflat.YtDlp.UpdateWorker},
67+
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
68+
{"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
69+
]}
5570
]
5671

5772
if config_env() == :prod do

lib/pinchflat/application.ex

+9-11
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ defmodule Pinchflat.Application do
99
@impl true
1010
def start(_type, _args) do
1111
check_and_update_timezone()
12+
attach_oban_telemetry()
13+
Logger.add_handlers(:pinchflat)
1214

13-
children = [
15+
# See https://hexdocs.pm/elixir/Supervisor.html
16+
# for other strategies and supported options
17+
[
1418
Pinchflat.PromEx,
1519
PinchflatWeb.Telemetry,
1620
Pinchflat.Repo,
@@ -24,17 +28,11 @@ defmodule Pinchflat.Application do
2428
{Finch, name: Pinchflat.Finch},
2529
# Start a worker by calling: Pinchflat.Worker.start_link(arg)
2630
# {Pinchflat.Worker, arg},
27-
# Start to serve requests, typically the last entry
28-
PinchflatWeb.Endpoint
31+
# Start to serve requests, typically the last entry (except for the post-boot tasks)
32+
PinchflatWeb.Endpoint,
33+
Pinchflat.Boot.PostBootStartupTasks
2934
]
30-
31-
attach_oban_telemetry()
32-
Logger.add_handlers(:pinchflat)
33-
34-
# See https://hexdocs.pm/elixir/Supervisor.html
35-
# for other strategies and supported options
36-
opts = [strategy: :one_for_one, name: Pinchflat.Supervisor]
37-
Supervisor.start_link(children, opts)
35+
|> Supervisor.start_link(strategy: :one_for_one, name: Pinchflat.Supervisor)
3836
end
3937

4038
# Tell Phoenix to update the endpoint configuration
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
defmodule Pinchflat.Boot.PostBootStartupTasks do
2+
@moduledoc """
3+
This module is responsible for running startup tasks on app boot
4+
AFTER all other boot steps have taken place and the app is ready to serve requests.
5+
6+
It's a GenServer because that plays REALLY nicely with the existing
7+
Phoenix supervision tree.
8+
"""
9+
10+
alias Pinchflat.YtDlp.UpdateWorker, as: YtDlpUpdateWorker
11+
12+
# restart: :temporary means that this process will never be restarted (ie: will run once and then die)
13+
use GenServer, restart: :temporary
14+
import Ecto.Query, warn: false
15+
16+
def start_link(opts \\ []) do
17+
GenServer.start_link(__MODULE__, %{env: Application.get_env(:pinchflat, :env)}, opts)
18+
end
19+
20+
@doc """
21+
Runs post-boot application startup tasks.
22+
23+
Any code defined here will run every time the application starts. You must
24+
make sure that the code is idempotent and safe to run multiple times.
25+
26+
This is a good place to set up default settings, create initial records, stuff like that.
27+
Should be fast - anything with the potential to be slow should be kicked off as a job instead.
28+
"""
29+
@impl true
30+
def init(%{env: :test} = state) do
31+
# Do nothing _as part of the app bootup process_.
32+
# Since bootup calls `start_link` and that's where the `env` state is injected,
33+
# you can still call `.init()` manually to run these tasks for testing purposes
34+
{:ok, state}
35+
end
36+
37+
def init(state) do
38+
update_yt_dlp()
39+
40+
{:ok, state}
41+
end
42+
43+
defp update_yt_dlp do
44+
YtDlpUpdateWorker.kickoff()
45+
end
46+
end

lib/pinchflat/boot/post_job_startup_tasks.ex

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Pinchflat.Boot.PostJobStartupTasks do
22
@moduledoc """
33
This module is responsible for running startup tasks on app boot
4-
AFTER the job runner has initiallized.
4+
AFTER the job runner has initialized.
55
66
It's a GenServer because that plays REALLY nicely with the existing
77
Phoenix supervision tree.
@@ -12,7 +12,7 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
1212
import Ecto.Query, warn: false
1313

1414
def start_link(opts \\ []) do
15-
GenServer.start_link(__MODULE__, %{}, opts)
15+
GenServer.start_link(__MODULE__, %{env: Application.get_env(:pinchflat, :env)}, opts)
1616
end
1717

1818
@doc """
@@ -25,6 +25,13 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
2525
Should be fast - anything with the potential to be slow should be kicked off as a job instead.
2626
"""
2727
@impl true
28+
def init(%{env: :test} = state) do
29+
# Do nothing _as part of the app bootup process_.
30+
# Since bootup calls `start_link` and that's where the `env` state is injected,
31+
# you can still call `.init()` manually to run these tasks for testing purposes
32+
{:ok, state}
33+
end
34+
2835
def init(state) do
2936
# Nothing at the moment!
3037

lib/pinchflat/boot/pre_job_startup_tasks.ex

+8-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
1919
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
2020

2121
def start_link(opts \\ []) do
22-
GenServer.start_link(__MODULE__, %{}, opts)
22+
GenServer.start_link(__MODULE__, %{env: Application.get_env(:pinchflat, :env)}, opts)
2323
end
2424

2525
@doc """
@@ -32,6 +32,13 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
3232
Should be fast - anything with the potential to be slow should be kicked off as a job instead.
3333
"""
3434
@impl true
35+
def init(%{env: :test} = state) do
36+
# Do nothing _as part of the app bootup process_.
37+
# Since bootup calls `start_link` and that's where the `env` state is injected,
38+
# you can still call `.init()` manually to run these tasks for testing purposes
39+
{:ok, state}
40+
end
41+
3542
def init(state) do
3643
ensure_tmpfile_directory()
3744
reset_executing_jobs()

lib/pinchflat/profiles/media_profile_deletion_worker.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule Pinchflat.Profiles.MediaProfileDeletionWorker do
1414
Starts the profile deletion worker. Does not attach it to a task like `kickoff_with_task/2`
1515
since deletion also cancels all tasks for the profile
1616
17-
Returns {:ok, %Task{}} | {:error, %Ecto.Changeset{}}
17+
Returns {:ok, %Oban.Job{}} | {:error, %Ecto.Changeset{}}
1818
"""
1919
def kickoff(profile, job_args \\ %{}, job_opts \\ []) do
2020
%{id: profile.id}

lib/pinchflat/yt_dlp/command_runner.ex

+18
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ defmodule Pinchflat.YtDlp.CommandRunner do
7676
end
7777
end
7878

79+
@doc """
80+
Updates yt-dlp to the latest version
81+
82+
Returns {:ok, binary()} | {:error, binary()}
83+
"""
84+
@impl YtDlpCommandRunner
85+
def update do
86+
command = backend_executable()
87+
88+
case CliUtils.wrap_cmd(command, ["--update"]) do
89+
{output, 0} ->
90+
{:ok, String.trim(output)}
91+
92+
{output, _} ->
93+
{:error, output}
94+
end
95+
end
96+
7997
defp generate_output_filepath(addl_opts) do
8098
case Keyword.get(addl_opts, :output_filepath) do
8199
nil -> FSUtils.generate_metadata_tmpfile(:json)

lib/pinchflat/yt_dlp/update_worker.ex

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule Pinchflat.YtDlp.UpdateWorker do
2+
@moduledoc false
3+
4+
use Oban.Worker,
5+
queue: :local_data,
6+
tags: ["local_data"]
7+
8+
require Logger
9+
10+
alias __MODULE__
11+
alias Pinchflat.Settings
12+
13+
@doc """
14+
Starts the yt-dlp update worker. Does not attach it to a task like `kickoff_with_task/2`
15+
16+
Returns {:ok, %Oban.Job{}} | {:error, %Ecto.Changeset{}}
17+
"""
18+
def kickoff do
19+
Oban.insert(UpdateWorker.new(%{}))
20+
end
21+
22+
@doc """
23+
Updates yt-dlp and saves the version to the settings.
24+
25+
This worker is scheduled to run via the Oban Cron plugin as well as on app boot.
26+
27+
Returns :ok
28+
"""
29+
@impl Oban.Worker
30+
def perform(%Oban.Job{}) do
31+
Logger.info("Updating yt-dlp")
32+
33+
yt_dlp_runner().update()
34+
35+
{:ok, yt_dlp_version} = yt_dlp_runner().version()
36+
Settings.set(yt_dlp_version: yt_dlp_version)
37+
38+
:ok
39+
end
40+
41+
defp yt_dlp_runner do
42+
Application.get_env(:pinchflat, :yt_dlp_runner)
43+
end
44+
end

lib/pinchflat/yt_dlp/yt_dlp_command_runner.ex

+1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ defmodule Pinchflat.YtDlp.YtDlpCommandRunner do
99
@callback run(binary(), atom(), keyword(), binary()) :: {:ok, binary()} | {:error, binary(), integer()}
1010
@callback run(binary(), atom(), keyword(), binary(), keyword()) :: {:ok, binary()} | {:error, binary(), integer()}
1111
@callback version() :: {:ok, binary()} | {:error, binary()}
12+
@callback update() :: {:ok, binary()} | {:error, binary()}
1213
end

lib/pinchflat_web/endpoint.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ defmodule PinchflatWeb.Endpoint do
2020
plug Plug.Static,
2121
at: "/",
2222
from: :pinchflat,
23-
gzip: Mix.env() == :prod,
23+
gzip: Application.compile_env(:pinchflat, :env) == :prod,
2424
only: PinchflatWeb.static_paths()
2525

2626
# Code reloading can be explicitly enabled under the
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule Pinchflat.Boot.PostBootStartupTasksTest do
2+
use Pinchflat.DataCase
3+
4+
alias Pinchflat.YtDlp.UpdateWorker
5+
alias Pinchflat.Boot.PostBootStartupTasks
6+
7+
describe "update_yt_dlp" do
8+
test "enqueues an update job" do
9+
assert [] = all_enqueued(worker: UpdateWorker)
10+
11+
PostBootStartupTasks.init(%{})
12+
13+
assert [%Oban.Job{}] = all_enqueued(worker: UpdateWorker)
14+
end
15+
end
16+
end

test/pinchflat/yt_dlp/command_runner_test.exs

+8
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ defmodule Pinchflat.YtDlp.CommandRunnerTest do
154154
end
155155
end
156156

157+
describe "update/0" do
158+
test "adds the update arg" do
159+
assert {:ok, output} = Runner.update()
160+
161+
assert String.contains?(output, "--update")
162+
end
163+
end
164+
157165
defp wrap_executable(new_executable, fun) do
158166
Application.put_env(:pinchflat, :yt_dlp_executable, new_executable)
159167
fun.()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
defmodule Pinchflat.YtDlp.UpdateWorkerTest do
2+
use Pinchflat.DataCase
3+
4+
alias Pinchflat.Settings
5+
alias Pinchflat.YtDlp.UpdateWorker
6+
7+
describe "perform/1" do
8+
test "calls the yt-dlp runner to update yt-dlp" do
9+
expect(YtDlpRunnerMock, :update, fn -> {:ok, ""} end)
10+
expect(YtDlpRunnerMock, :version, fn -> {:ok, ""} end)
11+
12+
perform_job(UpdateWorker, %{})
13+
end
14+
15+
test "saves the new version to the database" do
16+
expect(YtDlpRunnerMock, :update, fn -> {:ok, ""} end)
17+
expect(YtDlpRunnerMock, :version, fn -> {:ok, "1.2.3"} end)
18+
19+
perform_job(UpdateWorker, %{})
20+
21+
assert {:ok, "1.2.3"} = Settings.get(:yt_dlp_version)
22+
end
23+
end
24+
end

0 commit comments

Comments
 (0)