diff --git a/lib/pinchflat/downloading/download_option_builder.ex b/lib/pinchflat/downloading/download_option_builder.ex index 1b927b9..befea25 100644 --- a/lib/pinchflat/downloading/download_option_builder.ex +++ b/lib/pinchflat/downloading/download_option_builder.ex @@ -4,10 +4,10 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do """ alias Pinchflat.Sources - alias Pinchflat.Settings alias Pinchflat.Sources.Source alias Pinchflat.Media.MediaItem alias Pinchflat.Downloading.OutputPathBuilder + alias Pinchflat.Downloading.QualityOptionBuilder alias Pinchflat.Utils.FilesystemUtils, as: FSUtils @@ -142,27 +142,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do end defp quality_options(media_profile) do - vcodec = Settings.get!(:video_codec_preference) - acodec = Settings.get!(:audio_codec_preference) - container = media_profile.media_container - - case media_profile.preferred_resolution do - # Also be aware that :audio disabled all embedding options for subtitles - :audio -> - [:extract_audio, format_sort: "+acodec:#{acodec}", audio_format: container || "best"] - - resolution_atom -> - {resolution_string, _} = - resolution_atom - |> Atom.to_string() - |> Integer.parse() - - [ - # Since Plex doesn't support reading metadata from MKV - remux_video: container || "mp4", - format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}" - ] - end + QualityOptionBuilder.build(media_profile) end defp sponsorblock_options(media_profile) do diff --git a/lib/pinchflat/downloading/quality_option_builder.ex b/lib/pinchflat/downloading/quality_option_builder.ex new file mode 100644 index 0000000..cb89435 --- /dev/null +++ b/lib/pinchflat/downloading/quality_option_builder.ex @@ -0,0 +1,66 @@ +defmodule Pinchflat.Downloading.QualityOptionBuilder do + @moduledoc """ + A standalone builder module for building quality-related options for yt-dlp to download media. + + Currently exclusively used in DownloadOptionBuilder since this logic is too complex to just + place in the main module. + """ + + alias Pinchflat.Settings + alias Pinchflat.Profiles.MediaProfile + + @doc """ + Builds the quality-related options for yt-dlp to download media based on the given media profile + + Includes things like container, preferred format/codec, and audio track options. + """ + def build(%MediaProfile{preferred_resolution: :audio, media_container: container} = media_profile) do + acodec = Settings.get!(:audio_codec_preference) + + [ + :extract_audio, + format_sort: "+acodec:#{acodec}", + audio_format: container || "best", + format: build_format_string(media_profile) + ] + end + + def build(%MediaProfile{preferred_resolution: resolution_atom, media_container: container} = media_profile) do + vcodec = Settings.get!(:video_codec_preference) + acodec = Settings.get!(:audio_codec_preference) + {resolution_string, _} = resolution_atom |> Atom.to_string() |> Integer.parse() + + [ + # Since Plex doesn't support reading metadata from MKV + remux_video: container || "mp4", + format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}", + format: build_format_string(media_profile) + ] + end + + defp build_format_string(%MediaProfile{preferred_resolution: :audio, audio_track: audio_track}) do + if audio_track do + "bestaudio[#{build_format_modifier(audio_track)}]/bestaudio/best" + else + "bestaudio/best" + end + end + + defp build_format_string(%MediaProfile{audio_track: audio_track}) do + if audio_track do + "bestvideo+bestaudio[#{build_format_modifier(audio_track)}]/bestvideo*+bestaudio/best" + else + "bestvideo*+bestaudio/best" + end + end + + # Reminder to self: this conflicts with `--extractor-args "youtube:lang="` + # since that will translate the format_notes as well, which means they may not match. + # At least that's what happens now - worth a re-check if I have to come back to this + defp build_format_modifier("original"), do: "format_note*=original" + defp build_format_modifier("default"), do: "format_note*='(default)'" + # This uses the carat to anchor the language to the beginning of the string + # since that's what's needed to match `en` to `en-US` and `en-GB`, etc. The user + # can always specify the full language code if they want. + defp build_format_modifier(language_code), do: "language^=#{language_code}" +end diff --git a/lib/pinchflat/profiles/media_profile.ex b/lib/pinchflat/profiles/media_profile.ex index 3158bdf..8c9c61d 100644 --- a/lib/pinchflat/profiles/media_profile.ex +++ b/lib/pinchflat/profiles/media_profile.ex @@ -26,6 +26,7 @@ defmodule Pinchflat.Profiles.MediaProfile do sponsorblock_categories shorts_behaviour livestream_behaviour + audio_track preferred_resolution media_container redownload_delay_days @@ -65,6 +66,7 @@ defmodule Pinchflat.Profiles.MediaProfile do # See `build_format_clauses` in the Media context for more. field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include + field :audio_track, :string field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p" field :media_container, :string, default: nil diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/media_preview.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/media_preview.heex index d5d43be..6553f51 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_html/media_preview.heex +++ b/lib/pinchflat_web/controllers/media_items/media_item_html/media_preview.heex @@ -1,13 +1,13 @@ <%= if media_type(@media_item) == :video do %> <% end %> <%= if media_type(@media_item) == :audio do %> <% end %> diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex index 5d16070..76f8124 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex +++ b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex @@ -39,7 +39,10 @@ or - <.subtle_link href={~p"/media/#{@media_item.uuid}/stream"} target="_blank"> + <.subtle_link + href={~p"/media/#{@media_item.uuid}/stream?v=#{DateTime.to_unix(@media_item.updated_at)}"} + target="_blank" + > Open Local Stream diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex index 18e2ed1..fb83857 100644 --- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/media_profile_form.html.heex @@ -125,6 +125,16 @@ /> +
+ <.input + field={f[:audio_track]} + placeholder="de" + type="text" + label="Audio Track Language" + help="Only works if there are multiple audio tracks. Use either a language code, 'original' for the original audio track, or 'default' for YouTube's preference. Or just leave it blank" + /> +
+

Thumbnail Options

diff --git a/priv/repo/erd.png b/priv/repo/erd.png index 6237757..74613c1 100644 Binary files a/priv/repo/erd.png and b/priv/repo/erd.png differ diff --git a/priv/repo/migrations/20241127172054_add_audio_lang_to_media_profiles.exs b/priv/repo/migrations/20241127172054_add_audio_lang_to_media_profiles.exs new file mode 100644 index 0000000..b19d981 --- /dev/null +++ b/priv/repo/migrations/20241127172054_add_audio_lang_to_media_profiles.exs @@ -0,0 +1,9 @@ +defmodule Pinchflat.Repo.Migrations.AddAudioLangToMediaProfiles do + use Ecto.Migration + + def change do + alter table(:media_profiles) do + add :audio_track, :string + end + end +end diff --git a/test/pinchflat/downloading/download_option_builder_test.exs b/test/pinchflat/downloading/download_option_builder_test.exs index 97ad118..403d9a5 100644 --- a/test/pinchflat/downloading/download_option_builder_test.exs +++ b/test/pinchflat/downloading/download_option_builder_test.exs @@ -6,7 +6,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do alias Pinchflat.Sources alias Pinchflat.Profiles - alias Pinchflat.Settings alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Downloading.DownloadOptionBuilder @@ -253,21 +252,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do end describe "build/1 when testing media quality and format options" do - test "includes quality options" do - resolutions = ["360", "480", "720", "1080", "2160", "4320"] + # There are more tests inside QualityOptionBuilderTest + # This is essenitally just testing that we implement that module correctly - Enum.each(resolutions, fn resolution -> - resolution_atom = String.to_existing_atom(resolution <> "p") + test "includes video options for video profiles", %{media_item: media_item} do + assert {:ok, res} = DownloadOptionBuilder.build(media_item) - media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom}) - source = source_fixture(%{media_profile_id: media_profile.id}) - 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},+codec:avc:m4a"} in res - assert {:remux_video, "mp4"} in res - end) + assert {:format_sort, "res:1080,+codec:avc:m4a"} in res + assert {:remux_video, "mp4"} in res end test "includes quality options for audio only", %{media_item: media_item} do @@ -280,33 +272,6 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do 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") - - media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"}) - - assert {:ok, res} = DownloadOptionBuilder.build(media_item) - - assert {:format_sort, "res:1080,+codec:av01:aac"} in res - end - - test "includes custom remux target for videos if specified", %{media_item: media_item} do - media_item = update_media_profile_attribute(media_item, %{media_container: "mkv"}) - - assert {:ok, res} = DownloadOptionBuilder.build(media_item) - - assert {:remux_video, "mkv"} in res - end - - test "includes custom format target for audio if specified", %{media_item: media_item} do - media_item = update_media_profile_attribute(media_item, %{media_container: "flac", preferred_resolution: :audio}) - - assert {:ok, res} = DownloadOptionBuilder.build(media_item) - - assert {:audio_format, "flac"} in res - end end describe "build/1 when testing sponsorblock options" do diff --git a/test/pinchflat/downloading/quality_option_builder_test.exs b/test/pinchflat/downloading/quality_option_builder_test.exs new file mode 100644 index 0000000..84624f0 --- /dev/null +++ b/test/pinchflat/downloading/quality_option_builder_test.exs @@ -0,0 +1,109 @@ +defmodule Pinchflat.Downloading.QualityOptionBuilderTest do + use Pinchflat.DataCase + import Pinchflat.ProfilesFixtures + + alias Pinchflat.Profiles + alias Pinchflat.Settings + alias Pinchflat.Downloading.QualityOptionBuilder + + describe "build/1" do + test "includes format options if audio_track is set to original" do + media_profile = media_profile_fixture(%{audio_track: "original"}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format, "bestvideo+bestaudio[format_note*=original]/bestvideo*+bestaudio/best"} in res + end + + test "includes format options if audio_track is set to default" do + media_profile = media_profile_fixture(%{audio_track: "default"}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format, "bestvideo+bestaudio[format_note*='(default)']/bestvideo*+bestaudio/best"} in res + end + + test "includes format options if audio_track is set to a language code" do + media_profile = media_profile_fixture(%{audio_track: "en"}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format, "bestvideo+bestaudio[language^=en]/bestvideo*+bestaudio/best"} in res + end + end + + describe "build/1 when testing audio profiles" do + setup do + {:ok, media_profile: media_profile_fixture(%{preferred_resolution: :audio})} + end + + test "includes quality options for audio only", %{media_profile: media_profile} do + assert res = QualityOptionBuilder.build(media_profile) + + assert :extract_audio in res + assert {:format_sort, "+acodec:m4a"} in res + + refute {:remux_video, "mp4"} in res + end + + test "includes custom format target for audio if specified", %{media_profile: media_profile} do + {:ok, media_profile} = + Profiles.update_media_profile(media_profile, %{media_container: "flac", preferred_resolution: :audio}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:audio_format, "flac"} in res + end + + test "includes custom format options", %{media_profile: media_profile} do + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format, "bestaudio/best"} in res + end + end + + describe "build/1 when testing non-audio profiles" do + setup do + {:ok, media_profile: media_profile_fixture(%{preferred_resolution: :"480p"})} + end + + test "includes quality options" do + resolutions = ["360", "480", "720", "1080", "2160", "4320"] + + Enum.each(resolutions, fn resolution -> + resolution_atom = String.to_existing_atom(resolution <> "p") + media_profile = media_profile_fixture(%{preferred_resolution: resolution_atom}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res + assert {:remux_video, "mp4"} in res + end) + end + + test "includes custom quality options if specified", %{media_profile: media_profile} do + Settings.set(video_codec_preference: "av01") + Settings.set(audio_codec_preference: "aac") + + {:ok, media_profile} = Profiles.update_media_profile(media_profile, %{preferred_resolution: :"1080p"}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format_sort, "res:1080,+codec:av01:aac"} in res + end + + test "includes custom remux target for videos if specified", %{media_profile: media_profile} do + {:ok, media_profile} = Profiles.update_media_profile(media_profile, %{media_container: "mkv"}) + + assert res = QualityOptionBuilder.build(media_profile) + + assert {:remux_video, "mkv"} in res + end + + test "includes custom format options", %{media_profile: media_profile} do + assert res = QualityOptionBuilder.build(media_profile) + + assert {:format, "bestvideo*+bestaudio/best"} in res + end + end +end