Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9daef8b3b8 | |||
| f6708a327c | |||
| 582eb53698 | |||
| a20d06072f | |||
| 6d18130351 | |||
| af86ca1e0e | |||
| 4994e70652 |
@@ -11,6 +11,12 @@ on:
|
||||
options:
|
||||
- 'linux/amd64'
|
||||
- 'linux/amd64,linux/arm64'
|
||||
docker_tags:
|
||||
type: string
|
||||
description: 'Docker Tags'
|
||||
required: true
|
||||
default: 'dev'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -41,7 +47,8 @@ jobs:
|
||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||
type=raw,value=dev,enable=${{ github.event_name != 'release' && github.event_name != 'workflow_dispatch' }}
|
||||
type=raw,value=${{ inputs.docker_tags }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ services:
|
||||
- ./docker-run.dev.sh
|
||||
stdin_open: true
|
||||
tty: true
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
defmodule Pinchflat.Downloading.CodecParser do
|
||||
@moduledoc """
|
||||
Functions for generating yt-dlp codec strings
|
||||
"""
|
||||
|
||||
alias Pinchflat.Settings
|
||||
|
||||
@doc """
|
||||
Generate a video codec string based on the value of the video_codec_preference setting.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def generate_vcodec_string_from_settings do
|
||||
generate_vcodec_string(Settings.get!(:video_codec_preference))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate an audio codec string based on the value of the audio_codec_preference setting.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def generate_acodec_string_from_settings do
|
||||
generate_acodec_string(Settings.get!(:audio_codec_preference))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate a video codec string from a list of video codecs.
|
||||
|
||||
If the list is nil or empty, the default video codec is AVC.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def generate_vcodec_string(nil), do: "bestvideo[vcodec~='^avc']/bestvideo"
|
||||
def generate_vcodec_string([]), do: generate_vcodec_string(nil)
|
||||
|
||||
def generate_vcodec_string(video_codecs) do
|
||||
video_codecs
|
||||
|> Enum.map(&video_codec_map()[&1])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(&"bestvideo[vcodec~='^#{&1}']")
|
||||
|> Enum.concat(["bestvideo"])
|
||||
|> Enum.join("/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate an audio codec string from a list of audio codecs.
|
||||
|
||||
If the list is nil or empty, the default audio codec is MP4A.
|
||||
|
||||
Returns binary()
|
||||
"""
|
||||
def generate_acodec_string(nil), do: "bestaudio[acodec~='^mp4a']/bestaudio"
|
||||
def generate_acodec_string([]), do: generate_acodec_string(nil)
|
||||
|
||||
def generate_acodec_string(audio_codecs) do
|
||||
audio_codecs
|
||||
|> Enum.map(&audio_codec_map()[&1])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(&"bestaudio[acodec~='^#{&1}']")
|
||||
|> Enum.concat(["bestaudio"])
|
||||
|> Enum.join("/")
|
||||
end
|
||||
|
||||
@doc false
|
||||
def video_codec_map do
|
||||
%{
|
||||
"av01" => "av01",
|
||||
"avc" => "avc",
|
||||
"vp9" => "vp0?9"
|
||||
}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def audio_codec_map do
|
||||
%{
|
||||
"aac" => "aac",
|
||||
"mp4a" => "mp4a",
|
||||
"mp3" => "mp3",
|
||||
"opus" => "opus"
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -4,9 +4,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
"""
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.CodecParser
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
|
||||
@@ -122,13 +122,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
end
|
||||
|
||||
defp quality_options(media_profile) do
|
||||
vcodec_string = CodecParser.generate_vcodec_string_from_settings()
|
||||
acodec_string = CodecParser.generate_acodec_string_from_settings()
|
||||
vcodec = Settings.get!(:video_codec_preference)
|
||||
acodec = Settings.get!(:audio_codec_preference)
|
||||
|
||||
case media_profile.preferred_resolution do
|
||||
# Also be aware that :audio disabled all embedding options for subtitles
|
||||
:audio ->
|
||||
[:extract_audio, format: "#{acodec_string}/best"]
|
||||
[:extract_audio, format_sort: "+acodec:#{acodec}"]
|
||||
|
||||
resolution_atom ->
|
||||
{resolution_string, _} =
|
||||
@@ -137,10 +137,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
|> Integer.parse()
|
||||
|
||||
[
|
||||
format_sort: "res:#{resolution_string}",
|
||||
# Since Plex doesn't support reading metadata from MKV
|
||||
remux_video: "mp4",
|
||||
format: "((#{vcodec_string})+(#{acodec_string}))/best"
|
||||
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,7 +84,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||
# could fail and I want to handle that in isolation
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json)
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.FastIndexing.YoutubeApi
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@doc """
|
||||
Fetches new media IDs from a source's YouTube RSS feed, indexes them, and kicks off downloading
|
||||
Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
|
||||
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
|
||||
order of operations and how this fits into the indexing process.
|
||||
|
||||
@@ -26,7 +27,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
downloaded_.
|
||||
"""
|
||||
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
{:ok, media_ids} = get_recent_media_ids(source)
|
||||
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
|
||||
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||
|
||||
@@ -47,6 +48,17 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
Enum.filter(maybe_new_media_items, & &1)
|
||||
end
|
||||
|
||||
# If possible, use the YouTube API to fetch media IDs. If that fails, fall back to the RSS feed.
|
||||
# If the YouTube API isn't set up, just use the RSS feed.
|
||||
defp get_recent_media_ids(source) do
|
||||
with true <- YoutubeApi.enabled?(),
|
||||
{:ok, media_ids} <- YoutubeApi.get_recent_media_ids(source) do
|
||||
{:ok, media_ids}
|
||||
else
|
||||
_ -> YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
end
|
||||
|
||||
defp list_media_items_by_media_id_for(source, media_ids) do
|
||||
MediaQuery.new()
|
||||
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
defmodule Pinchflat.FastIndexing.YoutubeApi do
|
||||
@moduledoc """
|
||||
Methods for interacting with the YouTube API for fast indexing
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Utils.FunctionUtils
|
||||
alias Pinchflat.FastIndexing.YoutubeBehaviour
|
||||
|
||||
@behaviour YoutubeBehaviour
|
||||
|
||||
@doc """
|
||||
Determines if the YouTube API is enabled for fast indexing by checking
|
||||
if the user has an API key set
|
||||
|
||||
Returns boolean()
|
||||
"""
|
||||
@impl YoutubeBehaviour
|
||||
def enabled?(), do: is_binary(api_key())
|
||||
|
||||
@doc """
|
||||
Fetches the recent media IDs from the YouTube API for a given source.
|
||||
|
||||
Returns {:ok, [binary()]} | {:error, binary()}
|
||||
"""
|
||||
@impl YoutubeBehaviour
|
||||
def get_recent_media_ids(%Source{} = source) do
|
||||
api_response =
|
||||
source
|
||||
|> determine_playlist_id()
|
||||
|> do_api_request()
|
||||
|
||||
case api_response do
|
||||
{:ok, parsed_json} -> get_media_ids_from_response(parsed_json)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# The UC prefix is for channels which won't work with this API endpoint. Swapping
|
||||
# the prefix to UU will get us the playlist that represents the channel's uploads
|
||||
defp determine_playlist_id(%{collection_id: c_id}) do
|
||||
String.replace_prefix(c_id, "UC", "UU")
|
||||
end
|
||||
|
||||
defp do_api_request(playlist_id) do
|
||||
Logger.debug("Fetching recent media IDs from YouTube API for playlist: #{playlist_id}")
|
||||
|
||||
playlist_id
|
||||
|> construct_api_endpoint()
|
||||
|> http_client().get(accept: "application/json")
|
||||
|> case do
|
||||
{:ok, response} ->
|
||||
Phoenix.json_library().decode(response)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to fetch YouTube API: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_media_ids_from_response(parsed_json) do
|
||||
parsed_json
|
||||
|> Map.get("items", [])
|
||||
|> Enum.map(fn item ->
|
||||
item
|
||||
|> Map.get("contentDetails", %{})
|
||||
|> Map.get("videoId", nil)
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> FunctionUtils.wrap_ok()
|
||||
end
|
||||
|
||||
defp api_key do
|
||||
Settings.get!(:youtube_api_key)
|
||||
end
|
||||
|
||||
defp construct_api_endpoint(playlist_id) do
|
||||
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||
property_type = "contentDetails"
|
||||
max_results = 50
|
||||
|
||||
"#{api_base}?part=#{property_type}&maxResults=#{max_results}&playlistId=#{playlist_id}&key=#{api_key()}"
|
||||
end
|
||||
|
||||
defp http_client do
|
||||
Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
defmodule Pinchflat.FastIndexing.YoutubeBehaviour do
|
||||
@moduledoc """
|
||||
This module defines the behaviour for clients that interface with YouTube
|
||||
for the purpose of fast indexing.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
@callback enabled?() :: boolean()
|
||||
@callback get_recent_media_ids(%Source{}) :: {:ok, [String.t()]} | {:error, String.t()}
|
||||
end
|
||||
@@ -1,18 +1,31 @@
|
||||
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
||||
@moduledoc """
|
||||
Methods for interacting with YouTube RSS feeds
|
||||
Methods for interacting with YouTube RSS feeds for fast indexing
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.YoutubeBehaviour
|
||||
|
||||
@behaviour YoutubeBehaviour
|
||||
|
||||
@doc """
|
||||
Determines if the YouTube RSS feed is enabled for fast indexing. Used to satisfy
|
||||
the `YoutubeBehaviour` behaviour.
|
||||
|
||||
Returns true
|
||||
"""
|
||||
@impl YoutubeBehaviour
|
||||
def enabled?(), do: true
|
||||
|
||||
@doc """
|
||||
Fetches the recent media IDs from a YouTube RSS feed for a given source.
|
||||
|
||||
Returns {:ok, [binary()]} | {:error, binary()}
|
||||
"""
|
||||
def get_recent_media_ids_from_rss(%Source{} = source) do
|
||||
@impl YoutubeBehaviour
|
||||
def get_recent_media_ids(%Source{} = source) do
|
||||
Logger.debug("Fetching recent media IDs from YouTube RSS feed for source: #{source.collection_id}")
|
||||
|
||||
case http_client().get(rss_url_for_source(source)) do
|
||||
|
||||
@@ -21,9 +21,11 @@ defmodule Pinchflat.HTTP.HTTPClient do
|
||||
"""
|
||||
@impl HTTPBehaviour
|
||||
def get(url, headers \\ [], opts \\ []) do
|
||||
headers = parse_headers(headers)
|
||||
|
||||
case :httpc.request(:get, {url, headers}, [], opts) do
|
||||
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
|
||||
{:ok, body}
|
||||
{:ok, to_string(body)}
|
||||
|
||||
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
|
||||
{:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"}
|
||||
@@ -32,4 +34,8 @@ defmodule Pinchflat.HTTP.HTTPClient do
|
||||
{:error, "HTTP request failed: #{reason}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_headers(headers) do
|
||||
Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -130,7 +130,31 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||
end
|
||||
|
||||
# Run it on new records no matter what. The method we delegate to
|
||||
# will handle the case where `uploaded_at` is `nil`
|
||||
defp update_upload_date_index(%{data: %{id: nil}} = changeset) do
|
||||
do_update_upload_date_index(changeset)
|
||||
end
|
||||
|
||||
# For the update case, we only want to recalculate if the day itself has changed.
|
||||
# For instance, this is useful in the migration from `upload_date` to `uploaded_at`
|
||||
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
||||
old_uploaded_at = changeset.data.uploaded_at
|
||||
new_uploaded_at = get_change(changeset, :uploaded_at)
|
||||
upload_dates_match = DateTime.to_date(old_uploaded_at) == DateTime.to_date(new_uploaded_at)
|
||||
|
||||
if upload_dates_match do
|
||||
changeset
|
||||
else
|
||||
do_update_upload_date_index(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
# If the record is persisted and the `uploaded_at` field is not being changed,
|
||||
# we don't need to recalculate the index.
|
||||
defp update_upload_date_index(changeset), do: changeset
|
||||
|
||||
defp do_update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
|
||||
source_id = get_field(changeset, :source_id)
|
||||
source = Sources.get_source!(source_id)
|
||||
# Channels should count down from 99, playlists should count up from 0
|
||||
@@ -151,7 +175,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
end
|
||||
end
|
||||
|
||||
defp update_upload_date_index(changeset), do: changeset
|
||||
defp do_update_upload_date_index(changeset), do: changeset
|
||||
|
||||
defimpl Jason.Encoder, for: MediaItem do
|
||||
def encode(value, opts) do
|
||||
|
||||
@@ -11,6 +11,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@doc """
|
||||
Returns the directory where metadata for a database record should be stored.
|
||||
|
||||
@@ -54,35 +56,19 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
|
||||
@doc """
|
||||
Downloads and stores a thumbnail for a media item, returning the filepath.
|
||||
Chooses the highest quality thumbnail available (preferring jpg). Returns
|
||||
nil if no thumbnails are available.
|
||||
Chooses the highest quality thumbnail available and converts it to a JPG
|
||||
|
||||
Returns nil if no thumbnail is available or if yt-dlp encounters an error
|
||||
|
||||
Returns binary() | nil
|
||||
"""
|
||||
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
||||
thumbnails =
|
||||
(metadata_map["thumbnails"] || [])
|
||||
# Give it a low preference if the `preference` key doesn't exist
|
||||
|> Enum.map(&Map.put_new(&1, "preference", -1000))
|
||||
# Give it a low preference if image isn't a jpg
|
||||
|> Enum.map(fn t ->
|
||||
preference_weight = if String.ends_with?(t["url"], ".jpg"), do: t["preference"], else: t["preference"] - 1000
|
||||
def download_and_store_thumbnail_for(database_record) do
|
||||
yt_dlp_filepath = generate_filepath_for(database_record, "thumbnail.%(ext)s")
|
||||
real_filepath = generate_filepath_for(database_record, "thumbnail.jpg")
|
||||
|
||||
Map.put(t, "preference", preference_weight)
|
||||
end)
|
||||
|
||||
case Enum.sort_by(thumbnails, & &1["preference"], :desc) do
|
||||
[thumbnail_map | _] ->
|
||||
thumbnail_url = thumbnail_map["url"]
|
||||
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
|
||||
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
|
||||
|
||||
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
|
||||
|
||||
filepath
|
||||
|
||||
_ ->
|
||||
nil
|
||||
case YtDlpMedia.download_thumbnail(database_record.original_url, output: yt_dlp_filepath) do
|
||||
{:ok, _} -> real_filepath
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -138,13 +124,6 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_thumbnail_from_url(url) do
|
||||
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
||||
|
||||
body
|
||||
end
|
||||
|
||||
defp generate_filepath_for(database_record, filename) do
|
||||
Path.join([
|
||||
metadata_directory_for(database_record),
|
||||
|
||||
@@ -13,17 +13,15 @@ defmodule Pinchflat.Settings.Setting do
|
||||
:apprise_version,
|
||||
:apprise_server,
|
||||
:video_codec_preference,
|
||||
:audio_codec_preference
|
||||
]
|
||||
|
||||
@virtual_fields [
|
||||
:video_codec_preference_string,
|
||||
:audio_codec_preference_string
|
||||
:audio_codec_preference,
|
||||
:youtube_api_key
|
||||
]
|
||||
|
||||
@required_fields ~w(
|
||||
onboarding
|
||||
pro_enabled
|
||||
video_codec_preference
|
||||
audio_codec_preference
|
||||
)a
|
||||
|
||||
schema "settings" do
|
||||
@@ -32,43 +30,16 @@ defmodule Pinchflat.Settings.Setting do
|
||||
field :yt_dlp_version, :string
|
||||
field :apprise_version, :string
|
||||
field :apprise_server, :string
|
||||
field :youtube_api_key, :string
|
||||
|
||||
field :video_codec_preference, {:array, :string}, default: []
|
||||
field :audio_codec_preference, {:array, :string}, default: []
|
||||
field :video_codec_preference_string, :string, default: nil, virtual: true
|
||||
field :audio_codec_preference_string, :string, default: nil, virtual: true
|
||||
field :video_codec_preference, :string
|
||||
field :audio_codec_preference, :string
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(setting, attrs) do
|
||||
setting
|
||||
|> cast(attrs, @allowed_fields)
|
||||
|> cast(attrs, @virtual_fields, empty_values: [])
|
||||
|> convert_codec_preference_strings()
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
|
||||
defp convert_codec_preference_strings(changeset) do
|
||||
fields = [
|
||||
video_codec_preference_string: :video_codec_preference,
|
||||
audio_codec_preference_string: :audio_codec_preference
|
||||
]
|
||||
|
||||
Enum.reduce(fields, changeset, fn {virtual_field, actual_field}, changeset ->
|
||||
case get_change(changeset, virtual_field) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
value ->
|
||||
new_value =
|
||||
value
|
||||
|> String.split(">")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(String.trim(&1) == ""))
|
||||
|> Enum.map(&String.downcase/1)
|
||||
|
||||
put_change(changeset, actual_field, new_value)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -115,6 +115,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
||||
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
||||
|> validate_format(:original_url, youtube_channel_or_playlist_regex(), message: "must be a channel or playlist URL")
|
||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
|
||||
end
|
||||
@@ -128,7 +129,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
@doc false
|
||||
def fast_index_frequency do
|
||||
# minutes
|
||||
15
|
||||
10
|
||||
end
|
||||
|
||||
@doc false
|
||||
@@ -141,6 +142,13 @@ defmodule Pinchflat.Sources.Source do
|
||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||
end
|
||||
|
||||
def youtube_channel_or_playlist_regex do
|
||||
# Validate that the original URL is not a video URL
|
||||
# Also matches if the string does NOT contain youtube.com or youtu.be. This preserves my tenuous support
|
||||
# for non-youtube sources.
|
||||
~r<^(?:(?!youtube\.com/(watch|shorts|embed)|youtu\.be).)*$>
|
||||
end
|
||||
|
||||
defimpl Jason.Encoder, for: Source do
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|
||||
@@ -46,6 +46,20 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Downloads a thumbnail for a single piece of media. Usually used for
|
||||
downloading thumbnails for internal use
|
||||
|
||||
Returns {:ok, ""} | {:error, any, ...}.
|
||||
"""
|
||||
def download_thumbnail(url, command_opts \\ []) do
|
||||
opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
|
||||
|
||||
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
|
||||
# we aren't downloading the main media file
|
||||
backend_runner().run(url, opts, "after_move:%()j")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a map representing the media at the given URL.
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
alias Pinchflat.Downloading.CodecParser
|
||||
|
||||
embed_templates "setting_html/*"
|
||||
|
||||
@doc """
|
||||
@@ -16,9 +14,14 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
|
||||
def apprise_server_help do
|
||||
url = "https://github.com/caronc/apprise/wiki/URLBasics"
|
||||
classes = "underline decoration-bodydark decoration-1 hover:decoration-white"
|
||||
|
||||
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information)
|
||||
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">Apprise docs</a> for more information)
|
||||
end
|
||||
|
||||
def youtube_api_help do
|
||||
url = "https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key"
|
||||
|
||||
~s(API key for YouTube Data API v3. Greatly improves the accuracy of Fast Indexing. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">here</a> for details on generating an API key)
|
||||
end
|
||||
|
||||
def diagnostic_info_string do
|
||||
@@ -30,4 +33,8 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
- Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
||||
"""
|
||||
end
|
||||
|
||||
defp help_link_classes do
|
||||
"underline decoration-bodydark decoration-1 hover:decoration-white"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<aside>
|
||||
<h2 class="text-xl font-bold mb-2">Codec Preferences</h2>
|
||||
<h2 class="text-xl font-bold mb-2">Codec Preference</h2>
|
||||
<section class="ml-2 md:ml-4 mb-2 max-w-prose">
|
||||
<section>
|
||||
Available video codecs:
|
||||
<ul class="list-disc ml-8">
|
||||
<li :for={{codec, _} <- CodecParser.video_codec_map()}><%= codec %></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="mt-4">
|
||||
Available audio codecs:
|
||||
<ul class="list-disc ml-8">
|
||||
<li :for={{codec, _} <- CodecParser.audio_codec_map()}><%= codec %></li>
|
||||
</ul>
|
||||
</section>
|
||||
<p>
|
||||
Some users may want to specify the audio codec that <code class="text-sm">yt-dlp</code>
|
||||
searches for when downloading content. For these users, you can specify one audio and video
|
||||
codec to prefer above all others.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
NOTE: this is a <em>soft</em>
|
||||
preference. If the codec you specify is not available, <code class="text-sm">yt-dlp</code>
|
||||
will download the next best available codec. This is expected and intended behavior.
|
||||
<.inline_link href="https://github.com/yt-dlp/yt-dlp#sorting-formats">See here</.inline_link>
|
||||
for more information and a list of available codecs.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
Notification Settings
|
||||
</h3>
|
||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
@@ -26,7 +26,25 @@
|
||||
) %>
|
||||
</section>
|
||||
|
||||
<section class="mt-10" x-show="advancedMode">
|
||||
<section class="mt-8">
|
||||
<section>
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Indexing Settings
|
||||
</h3>
|
||||
|
||||
<.input
|
||||
field={f[:youtube_api_key]}
|
||||
placeholder="ABC123"
|
||||
type="text"
|
||||
label="YouTube API Key"
|
||||
help={youtube_api_help()}
|
||||
html_help={true}
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="mt-8" x-show="advancedMode">
|
||||
<section>
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Codec Options
|
||||
@@ -37,24 +55,20 @@
|
||||
</p>
|
||||
|
||||
<.input
|
||||
id="video_codec_preference_string"
|
||||
name="setting[video_codec_preference_string]"
|
||||
value={Enum.join(f[:video_codec_preference].value, ">")}
|
||||
placeholder="avc>vp9>av01"
|
||||
field={f[:video_codec_preference]}
|
||||
placeholder="avc"
|
||||
type="text"
|
||||
label="Video Codec Preference"
|
||||
help="Order of preference for video codecs. Separate with >. Will be remuxed into an MP4 container. See below for available codecs"
|
||||
help="Video codec preference. Will be remuxed into an MP4 container. See below for more details"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
|
||||
<.input
|
||||
id="audio_codec_preference_string"
|
||||
name="setting[audio_codec_preference_string]"
|
||||
value={Enum.join(f[:audio_codec_preference].value, ">")}
|
||||
placeholder="mp4a>opus>aac"
|
||||
field={f[:audio_codec_preference]}
|
||||
placeholder="m4a"
|
||||
type="text"
|
||||
label="Audio Codec Preference"
|
||||
help="Order of preference for audio codecs. Separate with >. See below for available codecs"
|
||||
help="Audio codec preference. See below for more details"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||
def friendly_index_frequencies do
|
||||
[
|
||||
{"Only once when first created", -1},
|
||||
{"30 minutes", 30},
|
||||
{"1 Hour", 60},
|
||||
{"3 Hours", 3 * 60},
|
||||
{"6 Hours", 6 * 60},
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
<aside>
|
||||
<h2 class="text-xl font-bold mb-2">What is fast indexing (experimental)?</h2>
|
||||
<h2 class="text-2xl font-bold mb-2">What is fast indexing?</h2>
|
||||
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
||||
<p>
|
||||
Indexing is the act of scanning a channel or playlist (aka: source) for new media.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Normal indexing uses <code class="text-sm">yt-dlp</code>
|
||||
to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source with a low index frequency will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time, so this can impact your other source's ability to index.
|
||||
to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source that's indexed frequently will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time so this can impact your other source's ability to index.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses an RSS feed to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes. It works well for most channels or playlists but it's not perfect.
|
||||
Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses a secondary mechanism (either RSS or YouTube's API) to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
RSS is used by default but you should enable the YouTube API if you want the best version of fast indexing. This isn't needed for most users but it provides the fastest and most reliable media updates.
|
||||
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
|
||||
Here is some documentation
|
||||
</.inline_link>
|
||||
on how to get your API key which you can add in the
|
||||
<.inline_link href={~p"/settings"}>
|
||||
settings
|
||||
</.inline_link>
|
||||
page.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
To make up for this limitation, a normal index is still run monthly to catch any videos that were missed by fast indexing. Fast indexing overrides the normal index frequency.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Fast indexing is experimental so please report any issues on GitHub. It's only recommended for sources with over 200-ish videos and that upload frequently. Not recommended for small or inactive sources.
|
||||
<p class="mt-4">
|
||||
<h4 class="font-bold text-xl">TL;DR</h4>
|
||||
|
||||
<strong class="mt-2 inline-block">In general:</strong>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li>
|
||||
Uses RSS by default which is fine for most users
|
||||
</li>
|
||||
<li>
|
||||
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
|
||||
Create a YouTube API key
|
||||
</.inline_link>
|
||||
and add it in your
|
||||
<.inline_link href={~p"/settings"}>
|
||||
settings
|
||||
</.inline_link>
|
||||
for the fastest possible media updates
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<strong class="mt-2 inline-block">Fast indexing is great if any of these apply:</strong>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li>The source is large channel and uploads frequently</li>
|
||||
<li>You want to download a source's new content as soon as possible</li>
|
||||
</ul>
|
||||
|
||||
<strong class="mt-2 inline-block">Consider <em>not</em> using fast indexing if any of these apply:</strong>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li>The source is a playlist</li>
|
||||
<li>The source has under 200 videos</li>
|
||||
<li>The source rarely uploads</li>
|
||||
<li>You don't mind if it takes longer for new content to be picked up</li>
|
||||
</ul>
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
General Options
|
||||
</h3>
|
||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
label="Index Frequency"
|
||||
x-bind:disabled="fastIndexingEnabled == true"
|
||||
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
|
||||
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting"
|
||||
help="Indexing is the process of checking for media to download. For best results, set this to the longest delay you can tolerate for this source"
|
||||
/>
|
||||
|
||||
<div phx-click={show_modal("upgrade-modal")}>
|
||||
@@ -58,7 +58,7 @@
|
||||
type="toggle"
|
||||
label="Use Fast Indexing"
|
||||
label_suffix="(pro)"
|
||||
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info"
|
||||
help="Not recommended for playlists. Overrides 'Index Frequency'. See below for more details (seriously, there's a TL;DR that's worth reading)"
|
||||
x-init="
|
||||
// `enabled` is the data attribute that the toggle uses internally
|
||||
fastIndexingEnabled = enabled
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "2024.5.30",
|
||||
version: "2024.6.10",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 468 KiB |
@@ -0,0 +1,15 @@
|
||||
defmodule Pinchflat.Repo.Migrations.RefactorCodecPreferences do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:settings) do
|
||||
remove :video_codec_preference, {:array, :string}, default: []
|
||||
remove :audio_codec_preference, {:array, :string}, default: []
|
||||
end
|
||||
|
||||
alter table(:settings) do
|
||||
add :video_codec_preference, :string, default: "avc", null: false
|
||||
add :audio_codec_preference, :string, default: "m4a", null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddYoutubeApiKeySetting do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:settings) do
|
||||
add :youtube_api_key, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,70 +0,0 @@
|
||||
defmodule Pinchflat.Downloading.CodecParserTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Downloading.CodecParser
|
||||
|
||||
describe "generate_vcodec_string_from_settings/1" do
|
||||
test "returns a default vcodec string when setting isn't set" do
|
||||
Settings.set(video_codec_preference: [])
|
||||
|
||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
||||
end
|
||||
|
||||
test "generates a vcodec string" do
|
||||
Settings.set(video_codec_preference: ["av01"])
|
||||
|
||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_acodec_string_from_settings/1" do
|
||||
test "returns a default acodec string when setting isn't set" do
|
||||
Settings.set(audio_codec_preference: [])
|
||||
|
||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
||||
end
|
||||
|
||||
test "generates an acodec string" do
|
||||
Settings.set(audio_codec_preference: ["mp3"])
|
||||
|
||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_vcodec_string/1" do
|
||||
test "returns a default vcodec string when nil" do
|
||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string(nil)
|
||||
end
|
||||
|
||||
test "returns a default vcodec string when empty" do
|
||||
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string([])
|
||||
end
|
||||
|
||||
test "generates a vcodec string" do
|
||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01"])
|
||||
end
|
||||
|
||||
test "ignores options that don't exist" do
|
||||
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01", "foo"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_acodec_string/1" do
|
||||
test "returns a default acodec string when nil" do
|
||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string(nil)
|
||||
end
|
||||
|
||||
test "returns a default acodec string when empty" do
|
||||
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string([])
|
||||
end
|
||||
|
||||
test "generates an acodec string" do
|
||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3"])
|
||||
end
|
||||
|
||||
test "ignores options that don't exist" do
|
||||
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3", "foo"])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -256,10 +256,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
assert {:format_sort, "res:#{resolution}"} in res
|
||||
|
||||
assert {:format, "((bestvideo[vcodec~='^avc']/bestvideo)+(bestaudio[acodec~='^mp4a']/bestaudio))/best"} in res
|
||||
|
||||
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
|
||||
assert {:remux_video, "mp4"} in res
|
||||
end)
|
||||
end
|
||||
@@ -270,20 +268,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert :extract_audio in res
|
||||
assert {:format, "bestaudio[acodec~='^mp4a']/bestaudio/best"} in res
|
||||
assert {:format_sort, "+acodec:m4a"} in res
|
||||
|
||||
refute {:remux_video, "mp4"} in res
|
||||
end
|
||||
|
||||
test "includes custom quality options if specified", %{media_item: media_item} do
|
||||
Settings.set(video_codec_preference: ["av01"])
|
||||
Settings.set(audio_codec_preference: ["aac"])
|
||||
Settings.set(video_codec_preference: "av01")
|
||||
Settings.set(audio_codec_preference: "aac")
|
||||
|
||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:format, "((bestvideo[vcodec~='^av01']/bestvideo)+(bestaudio[acodec~='^aac']/bestaudio))/best"} in res
|
||||
assert {:format_sort, "res:1080,+codec:av01:aac"} in res
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
setup do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
[:metadata, source: :media_profile]
|
||||
)
|
||||
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, ""}
|
||||
end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
|
||||
{:ok, %{media_item: media_item}}
|
||||
end
|
||||
@@ -44,7 +43,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||
|
||||
assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz"
|
||||
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
|
||||
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg"
|
||||
end
|
||||
|
||||
test "non-recoverable errors are passed through", %{media_item: media_item} do
|
||||
|
||||
@@ -6,19 +6,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
|
||||
setup do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, media_attributes_return_fixture()}
|
||||
end)
|
||||
|
||||
{:ok, [source: source_fixture()]}
|
||||
end
|
||||
|
||||
describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
|
||||
setup do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, media_attributes_return_fixture()}
|
||||
end)
|
||||
|
||||
{:ok, [source: source_fixture()]}
|
||||
end
|
||||
|
||||
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||
|
||||
@@ -107,4 +108,49 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
end
|
||||
end
|
||||
|
||||
describe "kickoff_download_tasks_from_youtube_rss_feed/1 when testing backends" do
|
||||
test "uses the YouTube API if it is enabled", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn url, _headers ->
|
||||
assert url =~ "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
Settings.set(youtube_api_key: "test_key")
|
||||
|
||||
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
end
|
||||
|
||||
test "the YouTube API creates records as expected", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url, _headers ->
|
||||
{:ok, ~s({ "items": [ {"contentDetails": {"videoId": "test_1"}} ] })}
|
||||
end)
|
||||
|
||||
Settings.set(youtube_api_key: "test_key")
|
||||
|
||||
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
end
|
||||
|
||||
test "RSS is used as a backup if the API fails", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, ""} end)
|
||||
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
|
||||
|
||||
Settings.set(youtube_api_key: "test_key")
|
||||
|
||||
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
end
|
||||
|
||||
test "RSS is used if the API is not enabled", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn url ->
|
||||
assert url =~ "https://www.youtube.com/feeds/videos.xml"
|
||||
|
||||
{:ok, "<yt:videoId>test_1</yt:videoId>"}
|
||||
end)
|
||||
|
||||
Settings.set(youtube_api_key: nil)
|
||||
|
||||
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
defmodule Pinchflat.FastIndexing.YoutubeApiTest do
|
||||
use Pinchflat.DataCase
|
||||
|
||||
import Pinchflat.SourcesFixtures
|
||||
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.FastIndexing.YoutubeApi
|
||||
|
||||
describe "enabled?/0" do
|
||||
test "returns true if the user has set a YouTube API key" do
|
||||
Settings.set(youtube_api_key: "test_key")
|
||||
|
||||
assert YoutubeApi.enabled?()
|
||||
end
|
||||
|
||||
test "returns false if the user has not set an API key" do
|
||||
Settings.set(youtube_api_key: nil)
|
||||
|
||||
refute YoutubeApi.enabled?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_recent_media_ids/1" do
|
||||
setup do
|
||||
source = source_fixture()
|
||||
Settings.set(youtube_api_key: "test_key")
|
||||
|
||||
{:ok, source: source}
|
||||
end
|
||||
|
||||
test "calls the expected URL", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn url, headers ->
|
||||
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
|
||||
request_url = "#{api_base}?part=contentDetails&maxResults=50&playlistId=#{source.collection_id}&key=test_key"
|
||||
|
||||
assert url == request_url
|
||||
assert headers == [accept: "application/json"]
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "replaces channel IDs with playlist IDs if needed" do
|
||||
source = source_fixture(collection_id: "UC_ABC123")
|
||||
|
||||
expect(HTTPClientMock, :get, fn url, _headers ->
|
||||
assert url =~ "playlistId=UU_ABC123&"
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns an empty list if no media is returned", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url, _headers -> {:ok, "{}"} end)
|
||||
|
||||
assert {:ok, []} = YoutubeApi.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns media IDs if present", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url, _headers ->
|
||||
{:ok,
|
||||
"""
|
||||
{
|
||||
"items": [
|
||||
{"contentDetails": {"videoId": "test_1"}},
|
||||
{"contentDetails": {"videoId": "test_2"}}
|
||||
]
|
||||
}
|
||||
"""}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1", "test_2"]} = YoutubeApi.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns an error if the HTTP request fails", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, "error"} end)
|
||||
|
||||
assert {:error, "error"} = YoutubeApi.get_recent_media_ids(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,7 +11,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, source: source}
|
||||
end
|
||||
|
||||
describe "get_recent_media_ids_from_rss/1" do
|
||||
describe "enabled?/0" do
|
||||
test "returns true" do
|
||||
assert YoutubeRss.enabled?()
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_recent_media_ids/1" do
|
||||
test "calls the expected URL for channel sources" do
|
||||
source = source_fixture(collection_type: :channel, collection_id: "channel_id")
|
||||
|
||||
@@ -21,7 +27,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "calls the expected URL for playlist sources" do
|
||||
@@ -33,13 +39,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns an error if the HTTP request fails", %{source: source} do
|
||||
expect(HTTPClientMock, :get, fn _url -> {:error, ""} end)
|
||||
|
||||
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns the media IDs from the RSS feed", %{source: source} do
|
||||
@@ -47,7 +53,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_2</yt:videoId>"}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "strips whitespace from media IDs", %{source: source} do
|
||||
@@ -55,7 +61,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, "<yt:videoId> test_1 </yt:videoId><yt:videoId> test_2 </yt:videoId>"}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "removes empty media IDs", %{source: source} do
|
||||
@@ -63,7 +69,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId></yt:videoId>"}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "removes duplicate media IDs", %{source: source} do
|
||||
@@ -71,7 +77,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_1</yt:videoId>"}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -740,14 +740,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
test "does delete the media item's metadata files" do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,14 +772,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
test "deletes the media item's metadata files" do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,14 +858,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
test "does not delete the media item's metadata files" do
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
media_item = Repo.preload(media_item_with_attachments(), :metadata)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,6 +967,18 @@ defmodule Pinchflat.MediaTest do
|
||||
|
||||
assert updated_media_item.upload_date_index == 99
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
|
||||
source = source_fixture(%{collection_type: :channel})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||
|
||||
{:ok, updated_media_item} =
|
||||
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
|
||||
|
||||
assert updated_media_item.upload_date_index == 99
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_media_item/1 when testing upload_date_index and source is a playlist" do
|
||||
@@ -1033,5 +1042,17 @@ defmodule Pinchflat.MediaTest do
|
||||
|
||||
assert updated_media_item.upload_date_index == 0
|
||||
end
|
||||
|
||||
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
|
||||
source = source_fixture(%{collection_type: :playlist})
|
||||
|
||||
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
|
||||
|
||||
{:ok, updated_media_item} =
|
||||
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
|
||||
|
||||
assert updated_media_item.upload_date_index == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,82 +50,37 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
||||
end
|
||||
|
||||
describe "download_and_store_thumbnail_for/2" do
|
||||
setup do
|
||||
# This tests that the HTTP endpoint is being called with every test
|
||||
expect(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, "thumbnail data"}
|
||||
test "returns the filepath", %{media_item: media_item} do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item)
|
||||
|
||||
assert filepath =~ ~r{/media_items/#{media_item.id}/thumbnail.jpg}
|
||||
end
|
||||
|
||||
test "calls yt-dlp with the expected options", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn url, opts, ot ->
|
||||
assert url == media_item.original_url
|
||||
assert ot == "after_move:%()j"
|
||||
|
||||
assert opts == [
|
||||
:no_simulate,
|
||||
:skip_download,
|
||||
:write_thumbnail,
|
||||
convert_thumbnail: "jpg",
|
||||
output: "/tmp/test/metadata/media_items/1/thumbnail.%(ext)s"
|
||||
]
|
||||
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
metadata = render_parsed_metadata(:media_metadata)
|
||||
|
||||
{:ok, %{metadata: metadata}}
|
||||
Helpers.download_and_store_thumbnail_for(media_item)
|
||||
end
|
||||
|
||||
test "returns the filepath", %{media_item: media_item, metadata: metadata} do
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
test "returns nil if yt-dlp fails", %{media_item: media_item} do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end)
|
||||
|
||||
assert filepath =~ ~r{/media_items/#{media_item.id}/maxresdefault.jpg}
|
||||
end
|
||||
|
||||
test "creates folder structure based on passed record", %{media_item: media_item, metadata: metadata} do
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
|
||||
assert File.exists?(Path.dirname(filepath))
|
||||
end
|
||||
|
||||
test "chooses the highest preference jpg thumbnail available", %{media_item: media_item} do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.jpg", "preference" => -1},
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_2.jpg", "preference" => 1},
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_3.jpg", "preference" => -10},
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_4.webp", "preference" => 10}
|
||||
]
|
||||
}
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
|
||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_2.jpg}
|
||||
end
|
||||
|
||||
test "will fall back to a non-jpg if it has to", %{media_item: media_item} do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp", "preference" => -1}
|
||||
]
|
||||
}
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
|
||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
|
||||
end
|
||||
|
||||
test "does not require a preference field", %{media_item: media_item} do
|
||||
metadata = %{
|
||||
"thumbnails" => [
|
||||
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp"}
|
||||
]
|
||||
}
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
|
||||
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
|
||||
end
|
||||
end
|
||||
|
||||
describe "download_and_store_thumbnail_for/2 when not downloading thumbnails" do
|
||||
test "returns nil if there are no thumbnails", %{media_item: media_item} do
|
||||
metadata = %{"thumbnails" => []}
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
|
||||
assert filepath == nil
|
||||
end
|
||||
|
||||
test "returns nil if there is no thumbnail field", %{media_item: media_item} do
|
||||
metadata = %{}
|
||||
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
|
||||
filepath = Helpers.download_and_store_thumbnail_for(media_item)
|
||||
|
||||
assert filepath == nil
|
||||
end
|
||||
|
||||
@@ -78,64 +78,4 @@ defmodule Pinchflat.SettingsTest do
|
||||
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_setting/2 when testing codec preferences" do
|
||||
test "converts (video|audio)_codec_preference_string to an array" do
|
||||
setting = Settings.record()
|
||||
|
||||
new_setting = %{
|
||||
video_codec_preference_string: "avc>vp9",
|
||||
audio_codec_preference_string: "aac>opus"
|
||||
}
|
||||
|
||||
changeset = Settings.change_setting(setting, new_setting)
|
||||
|
||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
||||
end
|
||||
|
||||
test "removes whitespace from (video|audio)_codec_preference" do
|
||||
setting = Settings.record()
|
||||
|
||||
new_setting = %{
|
||||
video_codec_preference_string: " avc > > vp9 ",
|
||||
audio_codec_preference_string: "aac> opus "
|
||||
}
|
||||
|
||||
changeset = Settings.change_setting(setting, new_setting)
|
||||
|
||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
||||
end
|
||||
|
||||
test "downcases (video|audio)_codec_preference" do
|
||||
setting = Settings.record()
|
||||
|
||||
new_setting = %{
|
||||
video_codec_preference_string: "AVC>VP9",
|
||||
audio_codec_preference_string: "AAC>OPUS"
|
||||
}
|
||||
|
||||
changeset = Settings.change_setting(setting, new_setting)
|
||||
|
||||
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
|
||||
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
|
||||
end
|
||||
|
||||
test "an empty value will remove the codec settings" do
|
||||
Settings.set(video_codec_preference: ["avc", "vp9"])
|
||||
Settings.set(audio_codec_preference: ["aac", "opus"])
|
||||
setting = Settings.record()
|
||||
|
||||
new_setting = %{
|
||||
video_codec_preference_string: "",
|
||||
audio_codec_preference_string: ""
|
||||
}
|
||||
|
||||
changeset = Settings.change_setting(setting, new_setting)
|
||||
|
||||
assert [] = changeset.changes.video_codec_preference
|
||||
assert [] = changeset.changes.audio_codec_preference
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -659,6 +659,61 @@ defmodule Pinchflat.SourcesTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_source/3 when testing original_url validation" do
|
||||
test "succeeds when an original URL is valid" do
|
||||
source = source_fixture()
|
||||
|
||||
valid_urls = [
|
||||
"https://www.youtube.com/channel/UCkRfArvrzheW2E7b6SVT7vQ",
|
||||
"https://www.youtube.com/channel/UCkRfArvrzheW2E7b6SVT7vQ/videos",
|
||||
"https://www.youtube.com/@youtubecreators/featured",
|
||||
"https://www.youtube.com/@youtubecreators",
|
||||
"https://www.youtube.com/c/YouTubeCreators",
|
||||
"https://www.youtube.com/user/YouTubeCreators",
|
||||
"https://www.youtube.com/YouTubeCreators",
|
||||
"https://www.youtube.com/playlist?list=PLpjK416fmKwRtq-9-O_NbZlkW0k6zu2Wn",
|
||||
"https://www.youtube.com/playlist?list=UUkRfArvrzheW2E7b6SVT7vQ"
|
||||
]
|
||||
|
||||
Enum.each(valid_urls, fn url ->
|
||||
assert %{errors: []} = Sources.change_source(source, %{original_url: url})
|
||||
end)
|
||||
end
|
||||
|
||||
test "fails when an original URL points to a video" do
|
||||
source = source_fixture()
|
||||
|
||||
invalid_urls = [
|
||||
"https://www.youtube.com/watch?v=72maj9FLQZI",
|
||||
"https://youtu.be/72maj9FLQZI",
|
||||
"https://www.youtube.com/watch?v=1FwGFhMAmBo&list=PLpjK416fmKwRtq-9-O_NbZlkW0k6zu2Wn",
|
||||
"https://www.youtube.com/shorts/Dq0eH-ZhQTU",
|
||||
"https://www.youtube.com/embed/X64LHlfx4qg"
|
||||
]
|
||||
|
||||
Enum.each(invalid_urls, fn url ->
|
||||
assert %{errors: [_]} = Sources.change_source(source, %{original_url: url})
|
||||
end)
|
||||
end
|
||||
|
||||
test "passes when a non-youtube link is provided" do
|
||||
source = source_fixture()
|
||||
|
||||
valid_urls = [
|
||||
"https://www.example.com",
|
||||
"https://www.example.com/playlist",
|
||||
"https://www.example.com/channel",
|
||||
"https://www.example.com/user",
|
||||
"https://www.example.com/watch?v=72maj9FLQZI",
|
||||
"https://www.example.com/embed/X64LHlfx4qg"
|
||||
]
|
||||
|
||||
Enum.each(valid_urls, fn url ->
|
||||
assert %{errors: []} = Sources.change_source(source, %{original_url: url})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp playlist_mock(_url, _opts, _ot) do
|
||||
{
|
||||
:ok,
|
||||
|
||||
@@ -7,8 +7,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
|
||||
@media_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
|
||||
|
||||
describe "download/2" do
|
||||
test "it calls the backend runner with the expected arguments" do
|
||||
describe "download/3" do
|
||||
test "calls the backend runner with the expected arguments" do
|
||||
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, addl ->
|
||||
assert [:no_simulate] = opts
|
||||
assert "after_move:%()j" = ot
|
||||
@@ -20,7 +20,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
assert {:ok, _} = Media.download(@media_url)
|
||||
end
|
||||
|
||||
test "it passes along additional options" do
|
||||
test "passes along additional options" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, addl ->
|
||||
assert [:no_simulate, :custom_arg] = opts
|
||||
assert [addl_arg: true] = addl
|
||||
@@ -31,7 +31,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
assert {:ok, _} = Media.download(@media_url, [:custom_arg], addl_arg: true)
|
||||
end
|
||||
|
||||
test "it parses and returns the generated file as JSON" do
|
||||
test "parses and returns the generated file as JSON" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
@@ -40,7 +40,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
Media.download(@media_url)
|
||||
end
|
||||
|
||||
test "it returns errors" do
|
||||
test "returns errors" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl ->
|
||||
{:error, "something"}
|
||||
end)
|
||||
@@ -49,6 +49,37 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "download_thumbnail/2" do
|
||||
test "calls the backend runner with the expected arguments" do
|
||||
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot ->
|
||||
assert opts == [:no_simulate, :skip_download, :write_thumbnail, {:convert_thumbnail, "jpg"}]
|
||||
assert ot == "after_move:%()j"
|
||||
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.download_thumbnail(@media_url)
|
||||
end
|
||||
|
||||
test "passes along additional options" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
|
||||
assert :custom_arg in opts
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Media.download_thumbnail(@media_url, [:custom_arg])
|
||||
end
|
||||
|
||||
test "returns errors" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot ->
|
||||
{:error, "something"}
|
||||
end)
|
||||
|
||||
assert {:error, "something"} = Media.download_thumbnail(@media_url)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_media_attributes/1" do
|
||||
test "returns a list of video attributes" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
|
||||
@@ -25,7 +25,7 @@ defmodule Pinchflat.SourcesFixtures do
|
||||
collection_type: "channel",
|
||||
custom_name: "Cool and good internal name!",
|
||||
description: "This is a description",
|
||||
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
|
||||
original_url: "https://www.youtube.com/@#{Faker.String.base64(12)}",
|
||||
media_profile_id: ProfilesFixtures.media_profile_fixture().id,
|
||||
index_frequency_minutes: 60
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user