diff --git a/.formatter.exs b/.formatter.exs index 6fc82a9..92e5016 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,5 @@ +# TODO: figure out why my vscode extension doesn't respect the formatter.exs file +# if it's in a subdirectory [ import_deps: [:ecto, :ecto_sql, :phoenix], subdirectories: ["priv/*/migrations"], diff --git a/.github/workflows/docker_release.yml b/.github/workflows/docker_release.yml index 3628ef2..f80cca4 100644 --- a/.github/workflows/docker_release.yml +++ b/.github/workflows/docker_release.yml @@ -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 @@ -68,7 +75,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: ./selfhosted.Dockerfile + file: ./docker/selfhosted.Dockerfile # If the event is a release, use the release_arch, otherwise use the # platforms input if present, falling back to dev_arch platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }} diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index 71e4ac8..82e62a6 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -16,24 +16,28 @@ jobs: if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" env: COMPOSE_FILE: ./docker-compose.ci.yml + MIX_ENV: test steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Pull prebuilt images run: docker compose pull - - name: Setup Docker layer caching - uses: jpribyl/action-docker-layer-caching@v0.1.1 - continue-on-error: true - with: - key: ci-docker-cache-{hash} - restore-keys: | - ci-docker-cache- - layer-ci-docker-cache- + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Build and Run Docker image + - name: Build docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/dev.Dockerfile + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run Docker image run: docker compose up --detach # NOTE: All exec commands use the -T flag to compensate for @@ -43,8 +47,7 @@ jobs: # See https://github.com/actions/runner/issues/241 and https://github.com/docker/compose/issues/8537 - name: Install Elixir and JS deps run: | - docker compose exec -T phx yarn install && cd assets && yarn install && cd .. - docker compose exec -T phx mix deps.get + docker compose exec -T phx mix deps.get && yarn install && cd assets && yarn install && cd .. - name: Create and Migrate database run: | diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index b6c9159..0000000 --- a/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -assets/vendor/ -deps/ -_build/ diff --git a/README.md b/README.md index 96b0c98..a550c87 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,8 @@ If it doesn't work for your use case, please make a feature request! You can als ## Screenshots -Pinchflat screenshot -Pinchflat screenshot +Pinchflat screenshot +Pinchflat screenshot ## Installation @@ -91,7 +91,7 @@ Docker Compose file: version: '3' services: pinchflat: - image: keglin/pinchflat:latest + image: ghcr.io/kieraneglin/pinchflat:latest environment: # Set the timezone to your local timezone - TZ=America/New_York @@ -108,7 +108,7 @@ services: 2. Prepare the docker image in one of the two ways below: - **From GHCR:** `docker pull ghcr.io/kieraneglin/pinchflat:latest` - NOTE: also available on Docker Hub at `keglin/pinchflat:latest` - - **Building locally:** `docker build . --file selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest` + - **Building locally:** `docker build . --file docker/selfhosted.Dockerfile -t ghcr.io/kieraneglin/pinchflat:latest` 3. Run the container: ```bash @@ -153,6 +153,7 @@ If you change this setting and it works well for you, please leave a comment on | EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) | | JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) | | TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored | +| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies | ## EFF donations diff --git a/assets/css/satoshi.css b/assets/css/satoshi.css index 272640a..bd622f8 100644 --- a/assets/css/satoshi.css +++ b/assets/css/satoshi.css @@ -22,8 +22,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-Light.woff2'), url('/fonts/satoshi/Satoshi-Light.woff'), - url('/fonts/satoshi/Satoshi-Light.ttf'); + src: url('../fonts/satoshi/Satoshi-Light.woff2'), url('../fonts/satoshi/Satoshi-Light.woff'), + url('../fonts/satoshi/Satoshi-Light.ttf'); font-weight: 300; font-display: swap; font-style: normal; @@ -31,8 +31,9 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-LightItalic.woff2'), - url('/fonts/satoshi/Satoshi-LightItalic.woff'), url('/fonts/satoshi/Satoshi-LightItalic.ttf'); + src: url('../fonts/satoshi/Satoshi-LightItalic.woff2'), + url('../fonts/satoshi/Satoshi-LightItalic.woff'), + url('../fonts/satoshi/Satoshi-LightItalic.ttf'); font-weight: 300; font-display: swap; font-style: italic; @@ -40,8 +41,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-Regular.woff2'), url('/fonts/satoshi/Satoshi-Regular.woff'), - url('/fonts/satoshi/Satoshi-Regular.ttf'); + src: url('../fonts/satoshi/Satoshi-Regular.woff2'), url('../fonts/satoshi/Satoshi-Regular.woff'), + url('../fonts/satoshi/Satoshi-Regular.ttf'); font-weight: 400; font-display: swap; font-style: normal; @@ -49,8 +50,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-Italic.woff2'), url('/fonts/satoshi/Satoshi-Italic.woff'), - url('/fonts/satoshi/Satoshi-Italic.ttf'); + src: url('../fonts/satoshi/Satoshi-Italic.woff2'), url('../fonts/satoshi/Satoshi-Italic.woff'), + url('../fonts/satoshi/Satoshi-Italic.ttf'); font-weight: 400; font-display: swap; font-style: italic; @@ -58,8 +59,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-Medium.woff2'), url('/fonts/satoshi/Satoshi-Medium.woff'), - url('/fonts/satoshi/Satoshi-Medium.ttf'); + src: url('../fonts/satoshi/Satoshi-Medium.woff2'), url('../fonts/satoshi/Satoshi-Medium.woff'), + url('../fonts/satoshi/Satoshi-Medium.ttf'); font-weight: 500; font-display: swap; font-style: normal; @@ -67,8 +68,9 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-MediumItalic.woff2'), - url('/fonts/satoshi/Satoshi-MediumItalic.woff'), url('/fonts/satoshi/Satoshi-MediumItalic.ttf'); + src: url('../fonts/satoshi/Satoshi-MediumItalic.woff2'), + url('../fonts/satoshi/Satoshi-MediumItalic.woff'), + url('../fonts/satoshi/Satoshi-MediumItalic.ttf'); font-weight: 500; font-display: swap; font-style: italic; @@ -76,8 +78,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-Bold.woff2'), url('/fonts/satoshi/Satoshi-Bold.woff'), - url('/fonts/satoshi/Satoshi-Bold.ttf'); + src: url('../fonts/satoshi/Satoshi-Bold.woff2'), url('../fonts/satoshi/Satoshi-Bold.woff'), + url('../fonts/satoshi/Satoshi-Bold.ttf'); font-weight: 700; font-display: swap; font-style: normal; @@ -85,8 +87,8 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-BoldItalic.woff2'), url('/fonts/satoshi/Satoshi-BoldItalic.woff'), - url('/fonts/satoshi/Satoshi-BoldItalic.ttf'); + src: url('../fonts/satoshi/Satoshi-BoldItalic.woff2'), + url('../fonts/satoshi/Satoshi-BoldItalic.woff'), url('../fonts/satoshi/Satoshi-BoldItalic.ttf'); font-weight: 700; font-display: swap; font-style: italic; @@ -94,8 +96,9 @@ @font-face { font-family: 'Satoshi'; - src: url('/fonts/satoshi/Satoshi-BlackItalic.woff2'), - url('/fonts/satoshi/Satoshi-BlackItalic.woff'), url('/fonts/satoshi/Satoshi-BlackItalic.ttf'); + src: url('../fonts/satoshi/Satoshi-BlackItalic.woff2'), + url('../fonts/satoshi/Satoshi-BlackItalic.woff'), + url('../fonts/satoshi/Satoshi-BlackItalic.ttf'); font-weight: 900; font-display: swap; font-style: italic; diff --git a/assets/js/app.js b/assets/js/app.js index de05354..2be596d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -29,7 +29,7 @@ window.Alpine = Alpine Alpine.start() let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') -let liveSocket = new LiveSocket('/live', Socket, { +let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, { params: { _csrf_token: csrfToken }, dom: { onBeforeElUpdated(from, to) { diff --git a/config/config.exs b/config/config.exs index 2407975..aab4d10 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,7 +26,8 @@ config :pinchflat, basic_auth_password: "", expose_feed_endpoints: false, file_watcher_poll_interval: 1000, - timezone: "UTC" + timezone: "UTC", + base_route_path: "/" config :pinchflat, Pinchflat.Repo, journal_mode: :wal, @@ -101,7 +102,7 @@ config :tailwind, # Configures Elixir's Logger config :logger, :console, - format: "$time $metadata[$level] $message\n", + format: "$date $time $metadata[$level] | $message\n", metadata: [:request_id] # Use Jason for JSON parsing in Phoenix diff --git a/config/runtime.exs b/config/runtime.exs index 8e47790..06aa731 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -46,12 +46,12 @@ if config_env() == :prod do log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"])) metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"])) extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"])) - # For running PF as a podcast host on self-hosted environments expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0 - # For testing alternate journal modes (see issue #137) journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal")) + # For running PF in a subdirectory via a reverse proxy + base_route_path = System.get_env("BASE_ROUTE_PATH", "/") config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug")) @@ -65,7 +65,8 @@ if config_env() == :prod do dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), expose_feed_endpoints: expose_feed_endpoints, timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC", - log_path: log_path + log_path: log_path, + base_route_path: base_route_path config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data") @@ -108,6 +109,7 @@ if config_env() == :prod do ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000") ], + url: [path: base_route_path], secret_key_base: secret_key_base config :pinchflat, :logger, [ diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index d4f4d6f..bf26c4b 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -1,13 +1,15 @@ -version: '3' services: phx: build: context: . - dockerfile: dev.Dockerfile + dockerfile: ./docker/dev.Dockerfile environment: - MIX_ENV=test volumes: - '.:/app' + # These lines ensure the deps can be saved as build artifacts for caching + - '/app/deps' + - '/app/_build' ports: - '4008:4008' command: tail -F /dev/null diff --git a/docker-compose.yml b/docker-compose.yml index 23cde70..aad86e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,14 @@ -version: '3' services: phx: build: context: . - dockerfile: dev.Dockerfile + dockerfile: ./docker/dev.Dockerfile volumes: - '.:/app' ports: - '4008:4008' - command: - - ./docker-run.dev.sh + command: bash -c "chmod +x docker/docker-run.dev.sh && docker/docker-run.dev.sh" stdin_open: true tty: true + env_file: + - .env diff --git a/dev.Dockerfile b/docker/dev.Dockerfile similarity index 55% rename from dev.Dockerfile rename to docker/dev.Dockerfile index 648aa55..3ebddd3 100644 --- a/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -1,6 +1,6 @@ -ARG ELIXIR_VERSION=1.16.2 -ARG OTP_VERSION=26.2.2 -ARG DEBIAN_VERSION=bookworm-20240130 +ARG ELIXIR_VERSION=1.17.0 +ARG OTP_VERSION=26.2.5 +ARG DEBIAN_VERSION=bookworm-20240612-slim ARG DEV_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" FROM ${DEV_IMAGE} @@ -9,10 +9,10 @@ ARG TARGETPLATFORM RUN echo "Building for ${TARGETPLATFORM:?}" # Install debian packages -RUN apt-get update -qq -RUN apt-get install -y inotify-tools curl git openssh-client jq \ - python3 python3-setuptools python3-wheel python3-dev pipx \ - python3-mutagen locales procps build-essential graphviz +RUN apt-get update -qq && \ + apt-get install -y inotify-tools curl git openssh-client jq \ + python3 python3-setuptools python3-wheel python3-dev pipx \ + python3-mutagen locales procps build-essential graphviz # Install ffmpeg RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ @@ -23,42 +23,37 @@ RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \ tar -xf /tmp/ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" -# Install nodejs -RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh -RUN bash nodesource_setup.sh -RUN apt-get install nodejs -RUN npm install -g yarn - -# Install baseline Elixir packages -RUN mix local.hex --force -RUN mix local.rebar --force - -# Download and update YT-DLP -RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp -RUN chmod a+rx /usr/local/bin/yt-dlp -RUN yt-dlp -U - -# Install Apprise -RUN export PIPX_HOME=/opt/pipx && \ - export PIPX_BIN_DIR=/usr/local/bin && \ - pipx install apprise +# Install nodejs and Yarn +RUN curl -sL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh && \ + bash nodesource_setup.sh && \ + apt-get install -y nodejs && \ + npm install -g yarn && \ + # Install baseline Elixir packages + mix local.hex --force && \ + mix local.rebar --force && \ + # Download and update YT-DLP + curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \ + chmod a+rx /usr/local/bin/yt-dlp && \ + yt-dlp -U && \ + # Install Apprise + export PIPX_HOME=/opt/pipx && \ + export PIPX_BIN_DIR=/usr/local/bin && \ + pipx install apprise # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 # Create app directory and copy the Elixir projects into it. WORKDIR /app COPY . ./ -# Needs permissions to be updated AFTER the copy step -RUN chmod +x ./docker-run.dev.sh - # Install Elixir deps # RUN mix archive.install github hexpm/hex branch latest -RUN mix deps.get +RUN MIX_ENV=dev mix deps.get && MIX_ENV=dev mix deps.compile +RUN MIX_ENV=test mix deps.get && MIX_ENV=test mix deps.compile # Gives us iex shell history ENV ERL_AFLAGS="-kernel shell_history enabled" diff --git a/docker-run.dev.sh b/docker/docker-run.dev.sh similarity index 56% rename from docker-run.dev.sh rename to docker/docker-run.dev.sh index ddae66f..8ebb1a8 100755 --- a/docker-run.dev.sh +++ b/docker/docker-run.dev.sh @@ -2,12 +2,12 @@ set -e -# Ensure the app's deps are installed +echo "\nInstalling Elixir deps..." mix deps.get -# Install JS deps -echo "\nInstalling JS..." -cd assets && yarn install +# Install both project-level and assets-level JS dependencies +echo "\nInstalling JS deps..." +yarn install && cd assets && yarn install cd .. # Potentially Set up the database diff --git a/selfhosted.Dockerfile b/docker/selfhosted.Dockerfile similarity index 97% rename from selfhosted.Dockerfile rename to docker/selfhosted.Dockerfile index 1101fc8..0449c88 100644 --- a/selfhosted.Dockerfile +++ b/docker/selfhosted.Dockerfile @@ -1,8 +1,8 @@ # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian # instead of Alpine to avoid DNS resolution issues in production. -ARG ELIXIR_VERSION=1.16.2 -ARG OTP_VERSION=26.2.2 -ARG DEBIAN_VERSION=bookworm-20240130-slim +ARG ELIXIR_VERSION=1.17.0 +ARG OTP_VERSION=26.2.5 +ARG DEBIAN_VERSION=bookworm-20240612-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" diff --git a/lib/pinchflat/downloading/codec_parser.ex b/lib/pinchflat/downloading/codec_parser.ex deleted file mode 100644 index 6953cf4..0000000 --- a/lib/pinchflat/downloading/codec_parser.ex +++ /dev/null @@ -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 diff --git a/lib/pinchflat/downloading/download_option_builder.ex b/lib/pinchflat/downloading/download_option_builder.ex index d508b26..3e04472 100644 --- a/lib/pinchflat/downloading/download_option_builder.ex +++ b/lib/pinchflat/downloading/download_option_builder.ex @@ -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 @@ -74,6 +74,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do {{:download_auto_subs, true}, %{download_subs: true}} -> acc ++ [:write_auto_subs] + {{:download_auto_subs, true}, %{embed_subs: true}} -> + acc ++ [:write_auto_subs] + {{:embed_subs, true}, %{preferred_resolution: pr}} when pr != :audio -> acc ++ [:embed_subs] @@ -122,13 +125,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 +140,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 @@ -204,10 +206,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do "source_collection_id" => source.collection_id, "source_collection_name" => source.collection_name, "source_collection_type" => to_string(source.collection_type), - "media_upload_date_index" => - media_item_with_preloads.upload_date_index - |> to_string() - |> String.pad_leading(2, "0") + "media_playlist_index" => pad_int(media_item_with_preloads.playlist_index), + "media_upload_date_index" => pad_int(media_item_with_preloads.upload_date_index) } end @@ -224,6 +224,12 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do |> build_output_path(media_item_with_preloads) end + defp pad_int(integer, count \\ 2, padding \\ "0") do + integer + |> to_string() + |> String.pad_leading(count, padding) + end + defp base_directory do Application.get_env(:pinchflat, :media_directory) end diff --git a/lib/pinchflat/downloading/downloading_helpers.ex b/lib/pinchflat/downloading/downloading_helpers.ex index cb679a8..5898533 100644 --- a/lib/pinchflat/downloading/downloading_helpers.ex +++ b/lib/pinchflat/downloading/downloading_helpers.ex @@ -91,8 +91,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do [m, s, mp], ^MediaQuery.for_source(source) and ^MediaQuery.downloaded() and - not (^MediaQuery.download_prevented()) and - not (^MediaQuery.culled()) + not (^MediaQuery.download_prevented()) ) ) |> Repo.all() diff --git a/lib/pinchflat/downloading/media_download_worker.ex b/lib/pinchflat/downloading/media_download_worker.ex index 42c5ca4..c7ea232 100644 --- a/lib/pinchflat/downloading/media_download_worker.ex +++ b/lib/pinchflat/downloading/media_download_worker.ex @@ -39,17 +39,14 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do - `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 | {:error, any, ...any} """ @impl Oban.Worker def perform(%Oban.Job{args: %{"id" => media_item_id} = args}) do should_force = Map.get(args, "force", false) is_quality_upgrade = Map.get(args, "quality_upgrade?", false) - media_item = - media_item_id - |> Media.get_media_item!() - |> Repo.preload(:source) + media_item = fetch_and_run_prevent_download_user_script(media_item_id) # 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 @@ -62,6 +59,20 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: media item #{media_item_id} stale") end + # If a user script exists and, when run, returns a non-zero exit code, prevent this and all future downloads + # of the media item. + defp fetch_and_run_prevent_download_user_script(media_item_id) do + media_item = Media.get_media_item!(media_item_id) + + {:ok, media_item} = + case run_user_script(:media_pre_download, media_item) do + {:ok, _, exit_code} when exit_code != 0 -> Media.update_media_item(media_item, %{prevent_download: true}) + _ -> {:ok, media_item} + end + + Repo.preload(media_item, :source) + end + defp download_media_and_schedule_jobs(media_item, is_quality_upgrade, should_force) do overwrite_behaviour = if should_force || is_quality_upgrade, do: :force_overwrites, else: :no_force_overwrites override_opts = [overwrite_behaviour: overwrite_behaviour] @@ -74,9 +85,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do media_redownloaded_at: get_redownloaded_at(is_quality_upgrade) }) - :ok = run_user_script(updated_media_item) + run_user_script(:media_downloaded, updated_media_item) - {:ok, updated_media_item} + :ok {:recovered, _} -> {:error, :retry} @@ -112,9 +123,9 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do # NOTE: I like this pattern of using the default value so that I don't have to # define it in config.exs (and friends). Consider using this elsewhere. - defp run_user_script(media_item) do + defp run_user_script(event, media_item) do runner = Application.get_env(:pinchflat, :user_script_runner, UserScriptRunner) - runner.run(:media_downloaded, media_item) + runner.run(event, media_item) end end diff --git a/lib/pinchflat/downloading/media_downloader.ex b/lib/pinchflat/downloading/media_downloader.ex index 7d4ab06..a10e6b9 100644 --- a/lib/pinchflat/downloading/media_downloader.ex +++ b/lib/pinchflat/downloading/media_downloader.ex @@ -79,12 +79,13 @@ defmodule Pinchflat.Downloading.MediaDownloader do |> MetadataParser.parse_for_media_item() |> Map.merge(%{ media_downloaded_at: DateTime.utc_now(), + culled_at: nil, nfo_filepath: determine_nfo_filepath(media_with_preloads, parsed_json), metadata: %{ # 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) } }) diff --git a/lib/pinchflat/downloading/media_quality_upgrade_worker.ex b/lib/pinchflat/downloading/media_quality_upgrade_worker.ex index 4f554fa..8fcfb22 100644 --- a/lib/pinchflat/downloading/media_quality_upgrade_worker.ex +++ b/lib/pinchflat/downloading/media_quality_upgrade_worker.ex @@ -23,10 +23,10 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do """ @impl Oban.Worker def perform(%Oban.Job{}) do - redownloadable_media = Media.list_redownloadable_media_items() - Logger.info("Redownloading #{length(redownloadable_media)} media items") + upgradable_media = Media.list_upgradeable_media_items() + Logger.info("Redownloading #{length(upgradable_media)} media items") - Enum.each(redownloadable_media, fn media_item -> + Enum.each(upgradable_media, fn media_item -> MediaDownloadWorker.kickoff_with_task(media_item, %{quality_upgrade?: true}) end) end diff --git a/lib/pinchflat/downloading/media_retention_worker.ex b/lib/pinchflat/downloading/media_retention_worker.ex index 354be2b..3a36100 100644 --- a/lib/pinchflat/downloading/media_retention_worker.ex +++ b/lib/pinchflat/downloading/media_retention_worker.ex @@ -6,8 +6,11 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]], tags: ["media_item", "local_data"] + use Pinchflat.Media.MediaQuery + require Logger + alias Pinchflat.Repo alias Pinchflat.Media @doc """ @@ -20,14 +23,51 @@ defmodule Pinchflat.Downloading.MediaRetentionWorker do """ @impl Oban.Worker def perform(%Oban.Job{}) do - cullable_media = Media.list_cullable_media_items() + cull_cullable_media_items() + delete_media_items_from_before_cutoff() + + :ok + end + + defp cull_cullable_media_items do + cullable_media = + MediaQuery.new() + |> MediaQuery.require_assoc(:source) + |> where(^MediaQuery.cullable()) + |> Repo.all() + Logger.info("Culling #{length(cullable_media)} media items past their retention date") Enum.each(cullable_media, fn media_item -> + # Setting `prevent_download` does what it says on the tin, but `culled_at` is purely informational. + # We don't actually do anything with that in terms of queries and it gets set to nil if the media item + # gets re-downloaded. Media.delete_media_files(media_item, %{ prevent_download: true, culled_at: DateTime.utc_now() }) end) end + + defp delete_media_items_from_before_cutoff do + deletable_media = + MediaQuery.new() + |> MediaQuery.require_assoc(:source) + |> where(^MediaQuery.deletable_based_on_source_cutoff()) + |> Repo.all() + + Logger.info("Deleting #{length(deletable_media)} media items that are from before the source cutoff") + + Enum.each(deletable_media, fn media_item -> + # Note that I'm not setting `prevent_download` on the media_item here. + # That's because cutoff_date can easily change and it's a valid behavior to re-download older + # media items if the cutoff_date changes. + # Download is ultimately prevented because `MediaQuery.pending()` only returns media items + # from after the cutoff date (among other things), so it's not like the media will just immediately + # be re-downloaded. + Media.delete_media_files(media_item, %{ + culled_at: DateTime.utc_now() + }) + end) + end end diff --git a/lib/pinchflat/downloading/output_path_builder.ex b/lib/pinchflat/downloading/output_path_builder.ex index d4f6e27..1c1392f 100644 --- a/lib/pinchflat/downloading/output_path_builder.ex +++ b/lib/pinchflat/downloading/output_path_builder.ex @@ -54,7 +54,12 @@ defmodule Pinchflat.Downloading.OutputPathBuilder do "season_from_date" => "%(upload_date>%Y)S", "season_episode_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S", "season_episode_index_from_date" => "s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}", - "artist_name" => "%(artist,creator,uploader,uploader_id)S" + "artist_name" => "%(artist,creator,uploader,uploader_id)S", + "static_season__episode_by_index" => "Season 1/s01e{{ media_playlist_index }}", + "static_season__episode_by_date" => "Season 1/s01e%(upload_date>%y%m%d)S", + "season_by_year__episode_by_date" => "Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S", + "season_by_year__episode_by_date_and_index" => + "Season %(upload_date>%Y)S/s%(upload_date>%Y)Se%(upload_date>%m%d)S{{ media_upload_date_index }}" } end end diff --git a/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex b/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex index 684ef49..02fc00f 100644 --- a/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex +++ b/lib/pinchflat/fast_indexing/fast_indexing_helpers.ex @@ -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)) diff --git a/lib/pinchflat/fast_indexing/youtube_api.ex b/lib/pinchflat/fast_indexing/youtube_api.ex new file mode 100644 index 0000000..95e6b39 --- /dev/null +++ b/lib/pinchflat/fast_indexing/youtube_api.ex @@ -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 diff --git a/lib/pinchflat/fast_indexing/youtube_behaviour.ex b/lib/pinchflat/fast_indexing/youtube_behaviour.ex new file mode 100644 index 0000000..8a6c390 --- /dev/null +++ b/lib/pinchflat/fast_indexing/youtube_behaviour.ex @@ -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 diff --git a/lib/pinchflat/fast_indexing/youtube_rss.ex b/lib/pinchflat/fast_indexing/youtube_rss.ex index 5caaab8..cb83fa9 100644 --- a/lib/pinchflat/fast_indexing/youtube_rss.ex +++ b/lib/pinchflat/fast_indexing/youtube_rss.ex @@ -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 diff --git a/lib/pinchflat/http/http_client.ex b/lib/pinchflat/http/http_client.ex index 45abda4..c7d4550 100644 --- a/lib/pinchflat/http/http_client.ex +++ b/lib/pinchflat/http/http_client.ex @@ -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 diff --git a/lib/pinchflat/lifecycle/user_scripts/command_runner.ex b/lib/pinchflat/lifecycle/user_scripts/command_runner.ex index 8436c05..54aa38c 100644 --- a/lib/pinchflat/lifecycle/user_scripts/command_runner.ex +++ b/lib/pinchflat/lifecycle/user_scripts/command_runner.ex @@ -12,6 +12,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do @behaviour UserScriptCommandRunner @event_types [ + :media_pre_download, :media_downloaded, :media_deleted ] @@ -22,24 +23,25 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do This function will succeed in almost all cases, even if the user script command failed - this is because I don't want bad scripts to stop the whole process. - If something fails, it'll be logged. + If something fails, it'll be logged and returned BUT the tuple will always + start with {:ok, ...}. The only things that can cause a true failure are passing in an invalid event type or if the passed data cannot be encoded into JSON - both indicative of failures in the development process. - Returns :ok + Returns {:ok, :no_executable} | {:ok, output, exit_code} """ @impl UserScriptCommandRunner def run(event_type, encodable_data) when event_type in @event_types do case backend_executable() do {:ok, :no_executable} -> - :ok + {:ok, :no_executable} {:ok, executable_path} -> {:ok, encoded_data} = Phoenix.json_library().encode(encodable_data) - {_output, _exit_code} = + {output, exit_code} = CliUtils.wrap_cmd( executable_path, [to_string(event_type), encoded_data], @@ -47,7 +49,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do logging_arg_override: "[suppressed]" ) - :ok + {:ok, output, exit_code} end end @@ -62,7 +64,7 @@ defmodule Pinchflat.Lifecycle.UserScripts.CommandRunner do if FilesystemUtils.exists_and_nonempty?(filepath) do {:ok, filepath} else - Logger.warning("User scripts lifecyle file either not present or is empty. Skipping.") + Logger.info("User scripts lifecyle file either not present or is empty. Skipping.") {:ok, :no_executable} end diff --git a/lib/pinchflat/media/media.ex b/lib/pinchflat/media/media.ex index 191b28f..013bf09 100644 --- a/lib/pinchflat/media/media.ex +++ b/lib/pinchflat/media/media.ex @@ -25,21 +25,10 @@ defmodule Pinchflat.Media do end @doc """ - Returns a list of media_items that are cullable based on the retention period - of the source they belong to. - - Returns [%MediaItem{}, ...] - """ - def list_cullable_media_items do - MediaQuery.new() - |> MediaQuery.require_assoc(:source) - |> where(^MediaQuery.cullable()) - |> Repo.all() - end - - @doc """ - Returns a list of media_items that are redownloadable based on the redownload delay - of the media_profile their source belongs to. + Returns a list of media_items that are upgradeable based on the redownload delay + of the media_profile their source belongs to. In this context, upgradeable means + that it's been long enough since upload that the video may be in a higher quality + or have better sponsorblock segments (or similar). The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days` @@ -52,10 +41,10 @@ defmodule Pinchflat.Media do Returns [%MediaItem{}, ...] """ - def list_redownloadable_media_items do + def list_upgradeable_media_items do MediaQuery.new() |> MediaQuery.require_assoc(:media_profile) - |> where(^MediaQuery.redownloadable()) + |> where(^MediaQuery.upgradeable()) |> Repo.all() end @@ -142,12 +131,17 @@ defmodule Pinchflat.Media do """ def create_media_item_from_backend_attrs(source, media_attrs_struct) do attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct)) + # Some fields should only be set on insert and not on update. + fields_to_drop_on_update = [:playlist_index] %MediaItem{} |> MediaItem.changeset(attrs) |> Repo.insert( on_conflict: [ - set: Map.to_list(attrs) + set: + attrs + |> Map.drop(fields_to_drop_on_update) + |> Map.to_list() ], conflict_target: [:source_id, :media_id] ) @@ -177,7 +171,7 @@ defmodule Pinchflat.Media do if delete_files do {:ok, _} = do_delete_media_files(media_item) - :ok = run_user_script(:media_deleted, media_item) + run_user_script(:media_deleted, media_item) end # Should delete these no matter what @@ -200,7 +194,7 @@ defmodule Pinchflat.Media do Tasks.delete_tasks_for(media_item) {:ok, _} = do_delete_media_files(media_item) - :ok = run_user_script(:media_deleted, media_item) + run_user_script(:media_deleted, media_item) update_media_item(media_item, Map.merge(filepath_attrs, addl_attrs)) end diff --git a/lib/pinchflat/media/media_item.ex b/lib/pinchflat/media/media_item.ex index bc4ec74..f85fcfb 100644 --- a/lib/pinchflat/media/media_item.ex +++ b/lib/pinchflat/media/media_item.ex @@ -18,6 +18,8 @@ defmodule Pinchflat.Media.MediaItem do alias Pinchflat.Media.MediaItemsSearchIndex @allowed_fields [ + # these fields are only captured on index + :playlist_index, # these fields are captured on indexing (and again on download) :title, :media_id, @@ -72,6 +74,7 @@ defmodule Pinchflat.Media.MediaItem do field :uploaded_at, :utc_datetime field :upload_date_index, :integer, default: 0 field :duration_seconds, :integer + field :playlist_index, :integer, default: 0 field :media_filepath, :string field :media_size_bytes, :integer @@ -130,7 +133,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 +178,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 diff --git a/lib/pinchflat/media/media_query.ex b/lib/pinchflat/media/media_query.ex index 2adb0ce..e038683 100644 --- a/lib/pinchflat/media/media_query.ex +++ b/lib/pinchflat/media/media_query.ex @@ -33,7 +33,6 @@ defmodule Pinchflat.Media.MediaQuery do def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath)) def download_prevented, do: dynamic([mi], mi.prevent_download == true) def culling_prevented, do: dynamic([mi], mi.prevent_culling == true) - def culled, do: dynamic([mi], not is_nil(mi.culled_at)) def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at)) def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date)) @@ -108,6 +107,15 @@ defmodule Pinchflat.Media.MediaQuery do ) end + def deletable_based_on_source_cutoff do + dynamic( + [mi, source], + ^downloaded() and + not (^upload_date_after_source_cutoff()) and + not (^culling_prevented()) + ) + end + def pending do dynamic( [mi], @@ -119,12 +127,11 @@ defmodule Pinchflat.Media.MediaQuery do ) end - def redownloadable do + def upgradeable do dynamic( [mi, source], ^downloaded() and not (^download_prevented()) and - not (^culled()) and not (^redownloaded()) and ^past_redownload_delay() ) diff --git a/lib/pinchflat/metadata/metadata_file_helpers.ex b/lib/pinchflat/metadata/metadata_file_helpers.ex index d54c803..b728f47 100644 --- a/lib/pinchflat/metadata/metadata_file_helpers.ex +++ b/lib/pinchflat/metadata/metadata_file_helpers.ex @@ -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. @@ -47,42 +49,26 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do Returns {:ok, map()} | {:error, any} """ def read_compressed_metadata(filepath) do - {:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all)) + {:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof)) Phoenix.json_library().decode(json) end @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,11 +124,19 @@ 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) + @doc """ + Attempts to determine the season and episode number from a media filepath. - body + Returns {:ok, {binary(), binary()}} | {:error, :indeterminable} + """ + def season_and_episode_from_media_filepath(media_filepath) do + # matches s + 1 or more digits + e + 1 or more digits (case-insensitive) + season_episode_regex = ~r/s(\d+)e(\d+)/i + + case Regex.scan(season_episode_regex, media_filepath) do + [[_, season, episode] | _] -> {:ok, {season, episode}} + _ -> {:error, :indeterminable} + end end defp generate_filepath_for(database_record, filename) do diff --git a/lib/pinchflat/metadata/nfo_builder.ex b/lib/pinchflat/metadata/nfo_builder.ex index 3db237f..38a26dd 100644 --- a/lib/pinchflat/metadata/nfo_builder.ex +++ b/lib/pinchflat/metadata/nfo_builder.ex @@ -6,8 +6,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do import Pinchflat.Utils.XmlUtils, only: [safe: 1] - alias Pinchflat.Metadata.MetadataFileHelpers alias Pinchflat.Utils.FilesystemUtils + alias Pinchflat.Metadata.MetadataFileHelpers @doc """ Builds an NFO file for a media item (read: single "episode") and @@ -15,12 +15,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do Returns the filepath of the NFO file. """ - def build_and_store_for_media_item(filepath, metadata) do - nfo = build_for_media_item(metadata) + def build_and_store_for_media_item(nfo_filepath, metadata) do + nfo = build_for_media_item(nfo_filepath, metadata) - FilesystemUtils.write_p!(filepath, nfo) + FilesystemUtils.write_p!(nfo_filepath, nfo) - filepath + nfo_filepath end @doc """ @@ -37,10 +37,15 @@ defmodule Pinchflat.Metadata.NfoBuilder do filepath end - defp build_for_media_item(metadata) do + defp build_for_media_item(nfo_filepath, metadata) do upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"]) + # NOTE: the filepath here isn't the path of the media item, it's the path that + # the NFO should be saved to. This works because the NFO's path is the same as + # the media's path, just with a different extension. If this ever changes I'll + # need to pass in the media item's path as well. + {season, episode} = determine_season_and_episode_number(nfo_filepath, upload_date) + # Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub. - # WHO NEEDS A FANCY XML PARSER ANYWAY?! """ @@ -49,8 +54,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do #{safe(metadata["id"])} #{safe(metadata["description"])} #{safe(upload_date)} - #{safe(upload_date.year)} - #{Calendar.strftime(upload_date, "%m%d")} + #{safe(season)} + #{episode} YouTube """ @@ -67,4 +72,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do """ end + + defp determine_season_and_episode_number(filepath, upload_date) do + case MetadataFileHelpers.season_and_episode_from_media_filepath(filepath) do + {:ok, {season, episode}} -> {season, episode} + {:error, _} -> {upload_date.year, Calendar.strftime(upload_date, "%m%d")} + end + end end diff --git a/lib/pinchflat/metadata/source_image_parser.ex b/lib/pinchflat/metadata/source_image_parser.ex index 9a0370c..26bacd7 100644 --- a/lib/pinchflat/metadata/source_image_parser.ex +++ b/lib/pinchflat/metadata/source_image_parser.ex @@ -20,29 +20,49 @@ defmodule Pinchflat.Metadata.SourceImageParser do def store_source_images(base_directory, source_metadata) do (source_metadata["thumbnails"] || []) |> Enum.filter(&(&1["filepath"] != nil)) - |> select_useful_images() + |> select_useful_images(source_metadata) |> Enum.map(&move_image(&1, base_directory)) |> Enum.into(%{}) end - defp select_useful_images(images) do + defp select_useful_images(images, source_metadata) do labelled_images = - Enum.reduce(images, [], fn image_map, acc -> + Enum.reduce(images, %{}, fn image_map, acc -> case image_map do - %{"id" => "avatar_uncropped"} -> - acc ++ [{:poster, :poster_filepath, image_map["filepath"]}] - - %{"id" => "banner_uncropped"} -> - acc ++ [{:fanart, :fanart_filepath, image_map["filepath"]}] - - _ -> - acc + %{"id" => "avatar_uncropped"} -> put_image_key(acc, :poster, image_map["filepath"]) + %{"id" => "banner_uncropped"} -> put_image_key(acc, :fanart, image_map["filepath"]) + _ -> acc end end) labelled_images - |> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}]) - |> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end) + |> add_fallback_poster(source_metadata) + |> put_image_key(:banner, determine_best_banner(images)) + |> Enum.filter(fn {_key, attrs} -> attrs.current_filepath end) + end + + # If a poster is set, short-circuit and return the images as-is + defp add_fallback_poster(%{poster: _} = images, _), do: images + + # If a poster is NOT set, see if we can find a suitable image to use as a fallback + defp add_fallback_poster(images, source_metadata) do + case source_metadata["entries"] do + nil -> images + [] -> images + [first_entry | _] -> add_poster_from_entry_thumbnail(images, first_entry) + end + end + + defp add_poster_from_entry_thumbnail(images, entry) do + thumbnail = + (entry["thumbnails"] || []) + |> Enum.reverse() + |> Enum.find(& &1["filepath"]) + + case thumbnail do + nil -> images + _ -> put_image_key(images, :poster, thumbnail["filepath"]) + end end defp determine_best_banner(images) do @@ -58,12 +78,22 @@ defmodule Pinchflat.Metadata.SourceImageParser do Map.get(best_candidate || %{}, "filepath") end - defp move_image({filename, source_attr_name, tmp_filepath}, base_directory) do - extension = Path.extname(tmp_filepath) - final_filepath = Path.join([base_directory, "#{filename}#{extension}"]) + defp move_image({_key, attrs}, base_directory) do + extension = Path.extname(attrs.current_filepath) + final_filepath = Path.join([base_directory, "#{attrs.final_filename}#{extension}"]) - FilesystemUtils.cp_p!(tmp_filepath, final_filepath) + FilesystemUtils.cp_p!(attrs.current_filepath, final_filepath) - {source_attr_name, final_filepath} + {attrs.attribute_name, final_filepath} + end + + defp put_image_key(map, key, image) do + attribute_atom = String.to_existing_atom("#{key}_filepath") + + Map.put(map, key, %{ + attribute_name: attribute_atom, + final_filename: to_string(key), + current_filepath: image + }) end end diff --git a/lib/pinchflat/metadata/source_metadata_storage_worker.ex b/lib/pinchflat/metadata/source_metadata_storage_worker.ex index 52ec810..8ccfdf9 100644 --- a/lib/pinchflat/metadata/source_metadata_storage_worker.ex +++ b/lib/pinchflat/metadata/source_metadata_storage_worker.ex @@ -77,10 +77,8 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do defp fetch_source_metadata_and_images(series_directory, source) do metadata_directory = MetadataFileHelpers.metadata_directory_for(source) - tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S" - opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: tmp_output_path] - {:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts) + {:ok, metadata} = fetch_metadata_for_source(source) metadata_image_attrs = SourceImageParser.store_source_images(metadata_directory, metadata) if source.media_profile.download_source_images && series_directory do @@ -110,6 +108,20 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do end end + defp fetch_metadata_for_source(source) do + tmp_output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S" + base_opts = [convert_thumbnails: "jpg", output: tmp_output_path] + + opts = + if source.collection_type == :channel do + base_opts ++ [:write_all_thumbnails, playlist_items: 0] + else + base_opts ++ [:write_thumbnail, playlist_items: 1] + end + + MediaCollection.get_source_metadata(source.original_url, opts) + end + defp tmp_directory do Application.get_env(:pinchflat, :tmpfile_directory) end diff --git a/lib/pinchflat/settings/setting.ex b/lib/pinchflat/settings/setting.ex index 8c393ff..d449ca0 100644 --- a/lib/pinchflat/settings/setting.ex +++ b/lib/pinchflat/settings/setting.ex @@ -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 diff --git a/lib/pinchflat/sources/source.ex b/lib/pinchflat/sources/source.ex index 02af5bd..418c828 100644 --- a/lib/pinchflat/sources/source.ex +++ b/lib/pinchflat/sources/source.ex @@ -112,9 +112,11 @@ defmodule Pinchflat.Sources.Source do |> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end) |> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end) |> validate_required(required_fields) + |> validate_title_regex() |> 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 +130,7 @@ defmodule Pinchflat.Sources.Source do @doc false def fast_index_frequency do # minutes - 15 + 10 end @doc false @@ -141,6 +143,22 @@ 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 + + defp validate_title_regex(%{changes: %{title_filter_regex: regex}} = changeset) when is_binary(regex) do + case Ecto.Adapters.SQL.query(Repo, "SELECT regexp_like('', ?)", [regex]) do + {:ok, _} -> changeset + _ -> add_error(changeset, :title_filter_regex, "is invalid") + end + end + + defp validate_title_regex(changeset), do: changeset + defimpl Jason.Encoder, for: Source do def encode(value, opts) do value diff --git a/lib/pinchflat/yt_dlp/media.ex b/lib/pinchflat/yt_dlp/media.ex index 106d6d0..cecf08a 100644 --- a/lib/pinchflat/yt_dlp/media.ex +++ b/lib/pinchflat/yt_dlp/media.ex @@ -22,7 +22,8 @@ defmodule Pinchflat.YtDlp.Media do :livestream, :short_form_content, :uploaded_at, - :duration_seconds + :duration_seconds, + :playlist_index ] alias __MODULE__ @@ -46,10 +47,24 @@ 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. - Returns {:ok, [map()]} | {:error, any, ...}. + Returns {:ok, %Media{}} | {:error, any, ...}. """ def get_media_attributes(url) do runner = Application.get_env(:pinchflat, :yt_dlp_runner) @@ -70,9 +85,11 @@ defmodule Pinchflat.YtDlp.Media do @doc """ Returns the output template for yt-dlp's indexing command. + + NOTE: playlist_index is really only useful for playlists that will never change their order. """ def indexing_output_template do - "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j" + "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j" end @doc """ @@ -90,7 +107,8 @@ defmodule Pinchflat.YtDlp.Media do livestream: !!response["was_live"], duration_seconds: response["duration"] && round(response["duration"]), short_form_content: response["webpage_url"] && short_form_content?(response), - uploaded_at: response["upload_date"] && parse_uploaded_at(response) + uploaded_at: response["upload_date"] && parse_uploaded_at(response), + playlist_index: response["playlist_index"] || 0 } end diff --git a/lib/pinchflat/yt_dlp/media_collection.ex b/lib/pinchflat/yt_dlp/media_collection.ex index 7e397ab..f657195 100644 --- a/lib/pinchflat/yt_dlp/media_collection.ex +++ b/lib/pinchflat/yt_dlp/media_collection.ex @@ -99,10 +99,21 @@ defmodule Pinchflat.YtDlp.MediaCollection do as a compressed blob for possible future use. That's why it's not getting formatted like `get_source_details/1` + ! IMPORTANT ! - you'll always want to set `playlist_items: int` in `addl_opts. + This is great if you want to also return details about the videos in the playlists, + but it should be set in all cases to not over-fetch data. + For channels you should usually set this to 0 since channels return all the + metadata we need without needing to fetch the videos. On the other hand, playlists + don't return very useful images so you can set this to 1 to get the first video's + images, for instance. + Returns {:ok, map()} | {:error, any, ...}. """ - def get_source_metadata(source_url, addl_opts \\ []) do - opts = [playlist_items: 0] ++ addl_opts + def get_source_metadata(source_url, addl_opts \\ [playlist_items: 0]) do + # This only validates that the `playlist_items` key is present. It's otherwise unused + _playlist_items = Keyword.fetch!(addl_opts, :playlist_items) + + opts = [:skip_download] ++ addl_opts output_template = "playlist:%()j" with {:ok, output} <- backend_runner().run(source_url, opts, output_template), diff --git a/lib/pinchflat_web/components/core_components.ex b/lib/pinchflat_web/components/core_components.ex index 58f785c..0bad772 100644 --- a/lib/pinchflat_web/components/core_components.ex +++ b/lib/pinchflat_web/components/core_components.ex @@ -19,6 +19,7 @@ defmodule PinchflatWeb.CoreComponents do import PinchflatWeb.Gettext alias Phoenix.LiveView.JS + alias PinchflatWeb.CustomComponents.TextComponents @doc """ Renders a modal. @@ -644,7 +645,11 @@ defmodule PinchflatWeb.CoreComponents do
  • <%= k %>: - <%= v %> + <%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %> + <%= v %> + <% else %> + <%= v %> + <% end %>
  • diff --git a/lib/pinchflat_web/components/layouts/root.html.heex b/lib/pinchflat_web/components/layouts/root.html.heex index 52f7e5f..ba87086 100644 --- a/lib/pinchflat_web/components/layouts/root.html.heex +++ b/lib/pinchflat_web/components/layouts/root.html.heex @@ -19,6 +19,7 @@ onboarding: #{Settings.get!(:onboarding)} }"} class="dark text-bodydark bg-boxdark-2" + data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")} > <%= @inner_content %> diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex index 44ed8da..13e4ca1 100644 --- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html.ex @@ -53,12 +53,24 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do ] end - def custom_output_template_options do + def media_center_custom_output_template_options do + %{ + season_by_year__episode_by_date: "Season YYYY/sYYYYeMMDD", + season_by_year__episode_by_date_and_index: + "same as the above but it handles dates better. This is the recommended option", + static_season__episode_by_index: + "Season 1/s01eXX where XX is the video's position in the playlist. Only recommended for playlists (not channels) that don't change", + static_season__episode_by_date: + "Season 1/s01eYYMMDD. Recommended for playlists that might change or where order isn't important" + } + end + + def other_custom_output_template_options do %{ upload_day: nil, upload_month: nil, upload_year: nil, - upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD", + upload_yyyy_mm_dd: "the upload date in the format YYYY-MM-DD", source_custom_name: "the name of the sources that use this profile", source_collection_id: "the YouTube ID of the sources that use this profile", source_collection_name: @@ -66,9 +78,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do source_collection_type: "the collection type of the sources using this profile. Either 'channel' or 'playlist'", artist_name: "the name of the artist with fallbacks to other uploader fields", season_from_date: "alias for upload_year", - season_episode_from_date: "the upload date formatted as sYYYYeMMDD", + season_episode_from_date: "the upload date formatted as sYYYYeMMDD", season_episode_index_from_date: - "the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions" + "the upload date formatted as sYYYYeMMDDII where II is an index to prevent date collisions", + media_playlist_index: + "the place of the media item in the playlist. Do not use with channels. May not work if the playlist is updated" } end @@ -77,7 +91,6 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do id ext title - fulltitle uploader channel upload_date @@ -99,7 +112,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do end defp media_center_output_template do - "/shows/{{ source_custom_name }}/Season {{ season_from_date }}/{{ season_episode_index_from_date }} - {{ title }}.{{ ext }}" + "/shows/{{ source_custom_name }}/{{ season_by_year__episode_by_date_and_index }} - {{ title }}.{{ ext }}" end defp audio_output_template do 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 c30b3df..a088aaa 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 @@ -84,22 +84,22 @@ /> -
    - <.input - field={f[:download_auto_subs]} - type="toggle" - label="Download Autogenerated Subtitles" - help="Prefers normal subs but will download autogenerated if needed. Requires 'Download Subtitles' to be enabled" - x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))" - /> -
    -
    <.input field={f[:embed_subs]} type="toggle" label="Embed Subtitles" - help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles' (recommended)" + help="Downloads and embeds subtitles in the media file itself, if supported. Uneffected by 'Download Subtitles'" + x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))" + /> +
    + +
    + <.input + field={f[:download_auto_subs]} + type="toggle" + label="Use Autogenerated Subtitles" + help="Prefers normal subs with 'Download Subtitles' or 'Embed Subtitles' but will use autogenerated subs if needed." x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))" />
    diff --git a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex index 7dd6df2..e3a1300 100644 --- a/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex +++ b/lib/pinchflat_web/controllers/media_profiles/media_profile_html/output_template_help.html.heex @@ -1,7 +1,7 @@ <%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>