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
-
-
+
+
## 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?!
"""
- <%= v %>
+ <%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
+ <%= v %>
+ <% else %>
+ <%= v %>
+ <% end %>
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 @@
/>
-