Compare commits

..

7 Commits

Author SHA1 Message Date
Kieran Eglin 2dfdb31aa4 Fixed release tag hopefully 2024-05-14 11:03:36 -07:00
Kieran Eglin c27e1dba96 Updated GH actions release template 2024-05-14 10:56:55 -07:00
Kieran Eglin 65b8790a35 Bumped version 2024-05-14 10:35:25 -07:00
Kieran Eglin b7c6f272a2 Updated README 2024-05-13 14:43:20 -07:00
Kieran a38ffbc55b [Enhancement] Allow redownloading of files for existing media items (#239)
* Added ability to specify overwrite behaviour when downloading media

* Added helper for redownloading media items

* renamed media redownload worker to disambiguate it from similarly named methods

* Added new redownload option to source actions dropdown

* Refactored MediaQuery to use a __using__ macro

* docs
2024-05-13 14:25:39 -07:00
Kieran Eglin 5c86e7192e Removed 'v' prefix from sidebar 2024-05-13 09:39:46 -07:00
Kieran 77a9ff4d9f Made tz data location user-configurable (#238) 2024-05-13 09:27:16 -07:00
34 changed files with 260 additions and 83 deletions
+1 -3
View File
@@ -40,9 +40,7 @@ jobs:
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch) # All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
tags: | tags: |
type=semver,pattern={{version}},prefix=v type=ref,event=tag
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
type=raw,value=dev,enable=${{ github.event_name != 'release' }} type=raw,value=dev,enable=${{ github.event_name != 'release' }}
flavor: | flavor: |
latest=auto latest=auto
+2 -2
View File
@@ -40,9 +40,9 @@
## What it does ## What it does
Pinchflat is a self-hosted app for downloading YouTube content built using [yt-dlp](https://github.com/yt-dlp/yt-dlp). It's designed to be lightweight, self-contained, and easy to use. You set up rules for how to download content from YouTube channels or playlists and it'll do the rest, checking periodically for new content. It's perfect for people who want to download content for use in with a media center app (Plex, Jellyfin, Kodi) or for those who want to archive media! Pinchflat is a self-hosted app for downloading YouTube content built using [yt-dlp](https://github.com/yt-dlp/yt-dlp). It's designed to be lightweight, self-contained, and easy to use. You set up rules for how to download content from YouTube channels or playlists and it'll do the rest, periodically checking for new content. It's perfect for people who want to download content for use in with a media center app (Plex, Jellyfin, Kodi) or for those who want to archive media!
It's _not_ great for downloading one-off videos - it's built to download large amounts of content and keep it up to date. It's also not meant for consuming content in-app - Pinchflat downloads content to disk where you can then watch it with a media center app or VLC. While you can [download individual videos](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-download-one-off-videos), Pinchflat is best suited for downloading content from channels or playlists. It's also not meant for consuming content in-app - Pinchflat downloads content to disk where you can then watch it with a media center app or VLC.
If it doesn't work for your use case, please make a feature request! You can also check out these great alternatives: [Tube Archivist](https://github.com/tubearchivist/tubearchivist), [ytdl-sub](https://github.com/jmbannon/ytdl-sub), and [TubeSync](https://github.com/meeb/tubesync) If it doesn't work for your use case, please make a feature request! You can also check out these great alternatives: [Tube Archivist](https://github.com/tubearchivist/tubearchivist), [ytdl-sub](https://github.com/jmbannon/ytdl-sub), and [TubeSync](https://github.com/meeb/tubesync)
+1 -1
View File
@@ -54,7 +54,7 @@ config :pinchflat, Oban,
{Oban.Plugins.Cron, {Oban.Plugins.Cron,
crontab: [ crontab: [
{"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker}, {"0 1 * * *", Pinchflat.Downloading.MediaRetentionWorker},
{"0 2 * * *", Pinchflat.Downloading.MediaRedownloadWorker} {"0 2 * * *", Pinchflat.Downloading.MediaQualityUpgradeWorker}
]} ]}
], ],
# TODO: consider making this an env var or something? # TODO: consider making this an env var or something?
-2
View File
@@ -7,8 +7,6 @@ import Config
# before starting your production server. # before starting your production server.
config :pinchflat, PinchflatWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" config :pinchflat, PinchflatWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
config :tzdata, :data_dir, "/etc/elixir_tzdata_data"
# Configures Swoosh API Client # Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Pinchflat.Finch config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Pinchflat.Finch
+2
View File
@@ -66,6 +66,8 @@ if config_env() == :prod do
expose_feed_endpoints: expose_feed_endpoints, expose_feed_endpoints: expose_feed_endpoints,
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC" timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: db_path, database: db_path,
journal_mode: journal_mode journal_mode: journal_mode
@@ -15,11 +15,11 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
Returns {:ok, [Keyword.t()]} Returns {:ok, [Keyword.t()]}
""" """
def build(%MediaItem{} = media_item_with_preloads) do def build(%MediaItem{} = media_item_with_preloads, override_opts \\ []) do
media_profile = media_item_with_preloads.source.media_profile media_profile = media_item_with_preloads.source.media_profile
built_options = built_options =
default_options() ++ default_options(override_opts) ++
subtitle_options(media_profile) ++ subtitle_options(media_profile) ++
thumbnail_options(media_item_with_preloads) ++ thumbnail_options(media_item_with_preloads) ++
metadata_options(media_profile) ++ metadata_options(media_profile) ++
@@ -50,11 +50,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
build_output_path_for(%MediaItem{source: source_with_preloads}) build_output_path_for(%MediaItem{source: source_with_preloads})
end end
defp default_options do defp default_options(override_opts) do
overwrite_behaviour = Keyword.get(override_opts, :overwrite_behaviour, :force_overwrites)
[ [
:no_progress, :no_progress,
# Add force-overwrites to make sure redownloading works overwrite_behaviour,
:force_overwrites,
# This makes the date metadata conform to what jellyfin expects # This makes the date metadata conform to what jellyfin expects
parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)" parse_metadata: "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"
] ]
@@ -7,6 +7,8 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
require Logger require Logger
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
@@ -64,4 +66,30 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
{:error, :should_not_download} {:error, :should_not_download}
end end
end end
@doc """
For a given source, enqueues download jobs for all media items _that have already been downloaded_.
This is useful for when a source's download settings have changed and you want to run through all
existing media and retry the download. For instance, if the source didn't originally download thumbnails
and you've changed the source to download them, you can use this to download all the thumbnails for
existing media items.
NOTE: does not delete existing files whatsoever. Does not overwrite the existing media file if it exists
at the location it expects. Will cause a full redownload of everything if the output template has changed
NOTE: unrelated to the MediaQualityUpgradeWorker, which is for redownloading media items for quality upgrades
or improved sponsorblock segments
Returns [{:ok, %Task{}} | {:error, any()}]
"""
def kickoff_redownload_for_existing_media(%Source{} = source) do
MediaQuery.new()
|> MediaQuery.for_source(source)
|> MediaQuery.with_media_downloaded_at()
|> MediaQuery.where_download_not_prevented()
|> MediaQuery.where_not_culled()
|> Repo.all()
|> Enum.map(&MediaDownloadWorker.kickoff_with_task/1)
end
end end
@@ -33,12 +33,18 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
Does not download media if its source is set to not download media Does not download media if its source is set to not download media
(unless forced). (unless forced).
Options:
- `force`: force download even if the source is set to not download media. Fully
re-downloads media, including the video
- `quality_upgrade?`: re-downloads media, including the video. Does not force download
if the source is set to not download media
Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any} Returns :ok | {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do
should_force = Map.get(args, "force", false) should_force = Map.get(args, "force", false)
is_redownload = Map.get(args, "redownload?", false) is_quality_upgrade = Map.get(args, "quality_upgrade?", false)
media_item = media_item =
media_item_id media_item_id
@@ -47,7 +53,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
# If the source or media item is set to not download media, perform a no-op unless forced # If the source or media item is set to not download media, perform a no-op unless forced
if (media_item.source.download_media && !media_item.prevent_download) || should_force do if (media_item.source.download_media && !media_item.prevent_download) || should_force do
download_media_and_schedule_jobs(media_item, is_redownload) download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force)
else else
:ok :ok
end end
@@ -56,13 +62,16 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale") Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale")
end end
defp download_media_and_schedule_jobs(media_item, is_redownload) do defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do
case MediaDownloader.download_for_media_item(media_item) do overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites
override_opts = [overwrite_behaviour: overwrite_behaviour]
case MediaDownloader.download_for_media_item(media_item, override_opts) do
{:ok, downloaded_media_item} -> {:ok, downloaded_media_item} ->
{:ok, updated_media_item} = {:ok, updated_media_item} =
Media.update_media_item(downloaded_media_item, %{ Media.update_media_item(downloaded_media_item, %{
media_size_bytes: compute_media_filesize(downloaded_media_item), media_size_bytes: compute_media_filesize(downloaded_media_item),
media_redownloaded_at: get_redownloaded_at(is_redownload) media_redownloaded_at: get_redownloaded_at(is_quality_upgrade)
}) })
:ok = run_user_script(updated_media_item) :ok = run_user_script(updated_media_item)
@@ -29,11 +29,11 @@ defmodule Pinchflat.Downloading.MediaDownloader do
Returns {:ok, %MediaItem{}} | {:error, any, ...any} Returns {:ok, %MediaItem{}} | {:error, any, ...any}
""" """
def download_for_media_item(%MediaItem{} = media_item) do def download_for_media_item(%MediaItem{} = media_item, override_opts \\ []) do
output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json) output_filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile]) media_with_preloads = Repo.preload(media_item, [:metadata, source: :media_profile])
case download_with_options(media_item.original_url, media_with_preloads, output_filepath) do case download_with_options(media_item.original_url, media_with_preloads, output_filepath, override_opts) do
{:ok, parsed_json} -> {:ok, parsed_json} ->
update_media_item_from_parsed_json(media_with_preloads, parsed_json) update_media_item_from_parsed_json(media_with_preloads, parsed_json)
@@ -103,8 +103,8 @@ defmodule Pinchflat.Downloading.MediaDownloader do
end end
end end
defp download_with_options(url, item_with_preloads, output_filepath) do defp download_with_options(url, item_with_preloads, output_filepath, override_opts) do
{:ok, options} = DownloadOptionBuilder.build(item_with_preloads) {:ok, options} = DownloadOptionBuilder.build(item_with_preloads, override_opts)
YtDlpMedia.download(url, options, output_filepath: output_filepath) YtDlpMedia.download(url, options, output_filepath: output_filepath)
end end
@@ -1,4 +1,4 @@
defmodule Pinchflat.Downloading.MediaRedownloadWorker do defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
@moduledoc false @moduledoc false
use Oban.Worker, use Oban.Worker,
@@ -12,7 +12,9 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorker do
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
@doc """ @doc """
Redownloads media items that are eligible for redownload. Redownloads media items that are eligible for redownload for the purpose
of upgrading the quality of the media or improving things like sponsorblock
segments.
This worker is scheduled to run daily via the Oban Cron plugin This worker is scheduled to run daily via the Oban Cron plugin
and it should run _after_ the retention worker. and it should run _after_ the retention worker.
@@ -25,7 +27,7 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorker do
Logger.info("Redownloading #{length(redownloadable_media)} media items") Logger.info("Redownloading #{length(redownloadable_media)} media items")
Enum.each(redownloadable_media, fn media_item -> Enum.each(redownloadable_media, fn media_item ->
MediaDownloadWorker.kickoff_with_task(media_item, %{redownload?: true}) MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true})
end) end)
end end
end end
@@ -7,10 +7,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
require Logger require Logger
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
@@ -5,8 +5,9 @@ defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
require Logger require Logger
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media.MediaQuery
@doc """ @doc """
Wraps a function that may change the number of pending or downloaded Wraps a function that may change the number of pending or downloaded
+1 -1
View File
@@ -4,12 +4,12 @@ defmodule Pinchflat.Media do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
+2 -1
View File
@@ -4,6 +4,8 @@ defmodule Pinchflat.Media.MediaItem do
""" """
use Ecto.Schema use Ecto.Schema
use Pinchflat.Media.MediaQuery
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils import Pinchflat.Utils.ChangesetUtils
@@ -12,7 +14,6 @@ defmodule Pinchflat.Media.MediaItem do
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Tasks.Task alias Pinchflat.Tasks.Task
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Media.MediaItemsSearchIndex alias Pinchflat.Media.MediaItemsSearchIndex
+11
View File
@@ -12,6 +12,17 @@ defmodule Pinchflat.Media.MediaQuery do
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
# This allows the module to be aliased and query methods to be used
# all in one go
# usage: use Pinchflat.Media.MediaQuery
defmacro __using__(_opts) do
quote do
import Ecto.Query, warn: false
alias unquote(__MODULE__)
end
end
# Prefixes: # Prefixes:
# - for_* - belonging to a certain record # - for_* - belonging to a certain record
# - join_* - for joining on a certain record # - join_* - for joining on a certain record
+2 -1
View File
@@ -4,8 +4,9 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
or its media items or its media items
""" """
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
+6 -3
View File
@@ -26,14 +26,17 @@ defmodule Pinchflat.Release do
load_app() load_app()
directories = directories =
Enum.uniq([ [
"/config", "/config",
"/downloads", "/downloads",
Application.get_env(:pinchflat, :media_directory), Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :tmpfile_directory), Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :extras_directory), Application.get_env(:pinchflat, :extras_directory),
Application.get_env(:pinchflat, :metadata_directory) Application.get_env(:pinchflat, :metadata_directory),
]) Application.get_env(:tzdata, :data_dir)
]
|> Enum.uniq()
|> Enum.filter(&(&1 != nil))
Enum.each(directories, fn dir -> Enum.each(directories, fn dir ->
Logger.info("Checking permissions for #{dir}") Logger.info("Checking permissions for #{dir}")
+2 -2
View File
@@ -4,12 +4,12 @@ defmodule Pinchflat.Sources do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Pinchflat.Repo use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@@ -27,21 +27,20 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
def table(assigns) do def table(assigns) do
~H""" ~H"""
<table class={["w-full table-auto", @table_class]}> <table class={["w-full table-auto bg-boxdark", @table_class]}>
<thead> <thead>
<tr class="bg-gray-2 text-left dark:bg-meta-4"> <tr class="text-left bg-meta-4">
<th :for={col <- @col} class="px-4 py-4 font-medium text-black dark:text-white xl:pl-11"> <th :for={col <- @col} class="px-4 py-4 font-medium text-white xl:pl-11">
<%= col[:label] %> <%= col[:label] %>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr :for={{row, i} <- Enum.with_index(@rows)}> <tr :for={row <- @rows} class="border-b border-boxdark hover:border-strokedark">
<td <td
:for={col <- @col} :for={col <- @col}
class={[ class={[
"px-4 py-5 pl-9 dark:border-strokedark xl:pl-11", "px-4 py-5 pl-9 xl:pl-11",
i + 1 > length(@rows) && "border-b border-[#eee] dark:border-π",
col[:class] col[:class]
]} ]}
> >
@@ -60,7 +60,7 @@
class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm" class="group relative flex items-center gap-2.5 px-4 pt-2 text-sm"
x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"} x-on:click={"markVersionAsSeen('#{Application.spec(:pinchflat)[:vsn]}')"}
> >
<span>Pinchflat v<%= Application.spec(:pinchflat)[:vsn] %></span> <span>Pinchflat <%= Application.spec(:pinchflat)[:vsn] %></span>
<a <a
href="https://github.com/kieraneglin/pinchflat/releases" href="https://github.com/kieraneglin/pinchflat/releases"
target="_blank" target="_blank"
@@ -1,9 +1,9 @@
defmodule PinchflatWeb.Pages.PageController do defmodule PinchflatWeb.Pages.PageController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
def home(conn, params) do def home(conn, params) do
@@ -1,9 +1,8 @@
defmodule Pinchflat.Pages.HistoryTableLive do defmodule Pinchflat.Pages.HistoryTableLive do
use PinchflatWeb, :live_view use PinchflatWeb, :live_view
import Ecto.Query, warn: false use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Utils.NumberUtils alias Pinchflat.Utils.NumberUtils
alias PinchflatWeb.CustomComponents.TextComponents alias PinchflatWeb.CustomComponents.TextComponents
@@ -1,10 +1,10 @@
defmodule PinchflatWeb.Podcasts.PodcastController do defmodule PinchflatWeb.Podcasts.PodcastController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Podcasts.RssFeedBuilder alias Pinchflat.Podcasts.RssFeedBuilder
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
@@ -104,7 +104,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|> redirect(to: ~p"/sources") |> redirect(to: ~p"/sources")
end end
def force_download(conn, %{"source_id" => id}) do def force_download_pending(conn, %{"source_id" => id}) do
wrap_forced_action( wrap_forced_action(
conn, conn,
id, id,
@@ -113,6 +113,15 @@ defmodule PinchflatWeb.Sources.SourceController do
) )
end end
def force_redownload(conn, %{"source_id" => id}) do
wrap_forced_action(
conn,
id,
"Forcing re-download of downloaded media items.",
&DownloadingHelpers.kickoff_redownload_for_existing_media/1
)
end
def force_index(conn, %{"source_id" => id}) do def force_index(conn, %{"source_id" => id}) do
wrap_forced_action( wrap_forced_action(
conn, conn,
@@ -31,11 +31,20 @@
</:option> </:option>
<:option :if={@source.download_media}> <:option :if={@source.download_media}>
<.link <.link
href={~p"/sources/#{@source}/force_download"} href={~p"/sources/#{@source}/force_download_pending"}
method="post" method="post"
data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed." data-confirm="Are you sure you want to force a download of all *pending* media items? This isn't normally needed."
> >
Force Download Download Pending
</.link>
</:option>
<:option :if={@source.download_media}>
<.link
href={~p"/sources/#{@source}/force_redownload"}
method="post"
data-confirm="Are you sure you want to re-download all currently downloaded media items? This isn't normally needed and won't change anything if the files already exist."
>
Redownload Existing
</.link> </.link>
</:option> </:option>
<:option> <:option>
@@ -1,10 +1,9 @@
defmodule Pinchflat.Sources.MediaItemTableLive do defmodule Pinchflat.Sources.MediaItemTableLive do
use PinchflatWeb, :live_view use PinchflatWeb, :live_view
import Ecto.Query, warn: false use Pinchflat.Media.MediaQuery
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Utils.NumberUtils alias Pinchflat.Utils.NumberUtils
@limit 10 @limit 10
+2 -1
View File
@@ -33,7 +33,8 @@ defmodule PinchflatWeb.Router do
resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true
resources "/sources", Sources.SourceController do resources "/sources", Sources.SourceController do
post "/force_download", Sources.SourceController, :force_download post "/force_download_pending", Sources.SourceController, :force_download_pending
post "/force_redownload", Sources.SourceController, :force_redownload
post "/force_index", Sources.SourceController, :force_index post "/force_index", Sources.SourceController, :force_index
post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh post "/force_metadata_refresh", Sources.SourceController, :force_metadata_refresh
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.18", version: "2024.5.14",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -65,6 +65,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert :force_overwrites in res assert :force_overwrites in res
assert {:parse_metadata, "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"} in res assert {:parse_metadata, "%(upload_date>%Y-%m-%d)s:(?P<meta_date>.+)"} in res
end end
test "includes override options if specified", %{media_item: media_item} do
assert {:ok, res} = DownloadOptionBuilder.build(media_item, overwrite_behaviour: :no_force_overwrites)
refute :force_overwrites in res
assert :no_force_overwrites in res
end
end end
describe "build/1 when testing subtitle options" do describe "build/1 when testing subtitle options" do
@@ -110,4 +110,32 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
refute_enqueued(worker: MediaDownloadWorker) refute_enqueued(worker: MediaDownloadWorker)
end end
end end
describe "kickoff_redownload_for_existing_media/1" do
test "enqueues a download job for each downloaded media item" do
source = source_fixture()
media_item = media_item_fixture(source_id: source.id, media_downloaded_at: now())
assert [{:ok, _}] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
assert_enqueued(worker: MediaDownloadWorker, args: %{"id" => media_item.id})
end
test "doesn't enqueue jobs for media that should be ignored" do
source = source_fixture()
other_source = source_fixture()
_not_downloaded = media_item_fixture(source_id: source.id, media_downloaded_at: nil)
_other_source = media_item_fixture(source_id: other_source.id, media_downloaded_at: now())
_download_prevented =
media_item_fixture(source_id: source.id, media_downloaded_at: now(), prevent_download: true)
_culled =
media_item_fixture(source_id: source.id, media_downloaded_at: now(), culled_at: now())
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
refute_enqueued(worker: MediaDownloadWorker)
end
end
end end
@@ -108,7 +108,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
end) end)
Oban.Testing.with_testing_mode(:inline, fn -> Oban.Testing.with_testing_mode(:inline, fn ->
{:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, redownload?: true})) {:ok, job} = Oban.insert(MediaDownloadWorker.new(%{id: media_item.id, quality_upgrade?: true}))
assert job.state == "completed" assert job.state == "completed"
end) end)
@@ -136,15 +136,6 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
perform_job(MediaDownloadWorker, %{id: media_item.id}) perform_job(MediaDownloadWorker, %{id: media_item.id})
end end
test "downloads anyway if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true})
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
test "it saves the file's size to the database", %{media_item: media_item} do test "it saves the file's size to the database", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
@@ -159,18 +150,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
assert media_item.media_size_bytes > 0 assert media_item.media_size_bytes > 0
end end
test "saves redownloaded_at if this is for a redownload", %{media_item: media_item} do test "does not set redownloaded_at by default", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, redownload?: true})
media_item = Repo.reload(media_item)
assert media_item.media_redownloaded_at != nil
end
test "doesn't save redownloaded_at if this is not for a redownload", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -198,5 +178,62 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
test "does not blow up if the record doesn't exist" do test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(MediaDownloadWorker, %{id: 0}) assert :ok = perform_job(MediaDownloadWorker, %{id: 0})
end end
test "sets the no_force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
assert :no_force_overwrites in opts
refute :force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id})
end
end
describe "perform/1 when testing forced downloads" do
test "ignores 'prevent_download' if forced", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> :ok end)
Sources.update_source(media_item.source, %{download_media: false})
Media.update_media_item(media_item, %{prevent_download: true})
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
end
describe "perform/1 when testing re-downloads" do
test "sets redownloaded_at on the media_item", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, quality_upgrade?: true})
media_item = Repo.reload(media_item)
assert media_item.media_redownloaded_at != nil
end
test "sets force_overwrites runner option", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
assert :force_overwrites in opts
refute :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
end)
perform_job(MediaDownloadWorker, %{id: media_item.id, force: true})
end
end end
end end
@@ -65,6 +65,21 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
end end
end end
describe "download_for_media_item/3 when testing override options" do
test "includes override opts if specified", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, _addl ->
refute :force_overwrites in opts
assert :no_force_overwrites in opts
{:ok, render_metadata(:media_metadata)}
end)
override_opts = [overwrite_behaviour: :no_force_overwrites]
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item, override_opts)
end
end
describe "download_for_media_item/3 when testing retries" do describe "download_for_media_item/3 when testing retries" do
test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do test "returns a recovered tuple on recoverable errors", %{media_item: media_item} do
message = "Unable to communicate with SponsorBlock" message = "Unable to communicate with SponsorBlock"
@@ -1,4 +1,4 @@
defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
@@ -6,7 +6,7 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.Downloading.MediaRedownloadWorker alias Pinchflat.Downloading.MediaQualityUpgradeWorker
describe "perform/1" do describe "perform/1" do
test "kicks off a task for redownloadable media items" do test "kicks off a task for redownloadable media items" do
@@ -20,9 +20,9 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do
media_downloaded_at: now_minus(5, :days) media_downloaded_at: now_minus(5, :days)
}) })
perform_job(MediaRedownloadWorker, %{}) perform_job(MediaQualityUpgradeWorker, %{})
assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{id: media_item.id, redownload?: true}) assert [_] = all_enqueued(worker: MediaDownloadWorker, args: %{id: media_item.id, quality_upgrade?: true})
end end
test "does not kickoff a task for non-redownloadable media items" do test "does not kickoff a task for non-redownloadable media items" do
@@ -36,7 +36,7 @@ defmodule Pinchflat.Downloading.MediaRedownloadWorkerTest do
media_downloaded_at: now_minus(1, :day) media_downloaded_at: now_minus(1, :day)
}) })
perform_job(MediaRedownloadWorker, %{}) perform_job(MediaQualityUpgradeWorker, %{})
assert [] = all_enqueued(worker: MediaDownloadWorker) assert [] = all_enqueued(worker: MediaDownloadWorker)
end end
@@ -166,20 +166,38 @@ defmodule PinchflatWeb.SourceControllerTest do
end end
end end
describe "force_download" do describe "force_download_pending" do
test "enqueues pending download tasks", %{conn: conn} do test "enqueues pending download tasks", %{conn: conn} do
source = source_fixture() source = source_fixture()
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil}) _media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
assert [] = all_enqueued(worker: MediaDownloadWorker) assert [] = all_enqueued(worker: MediaDownloadWorker)
post(conn, ~p"/sources/#{source.id}/force_download") post(conn, ~p"/sources/#{source.id}/force_download_pending")
assert [_] = all_enqueued(worker: MediaDownloadWorker) assert [_] = all_enqueued(worker: MediaDownloadWorker)
end end
test "redirects to the source page", %{conn: conn} do test "redirects to the source page", %{conn: conn} do
source = source_fixture() source = source_fixture()
conn = post(conn, ~p"/sources/#{source.id}/force_download") conn = post(conn, ~p"/sources/#{source.id}/force_download_pending")
assert redirected_to(conn) == ~p"/sources/#{source.id}"
end
end
describe "force_redownload" do
test "enqueues re-download tasks", %{conn: conn} do
source = source_fixture()
_media_item = media_item_fixture(source_id: source.id, media_downloaded_at: now())
assert [] = all_enqueued(worker: MediaDownloadWorker)
post(conn, ~p"/sources/#{source.id}/force_redownload")
assert [_] = all_enqueued(worker: MediaDownloadWorker)
end
test "redirects to the source page", %{conn: conn} do
source = source_fixture()
conn = post(conn, ~p"/sources/#{source.id}/force_redownload")
assert redirected_to(conn) == ~p"/sources/#{source.id}" assert redirected_to(conn) == ~p"/sources/#{source.id}"
end end
end end