Compare commits
22 Commits
v2024.6.10
...
v2024.7.18
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc25e5730 | |||
| a3ebf59b29 | |||
| 8dd8e49051 | |||
| 029981b7ee | |||
| d423e4486f | |||
| 4c7a63c970 | |||
| 4822127e3f | |||
| 4f25878f2f | |||
| aec56e3a03 | |||
| e06e050f95 | |||
| 5a10015634 | |||
| 0d5a41fd35 | |||
| 8f91c4e6a2 | |||
| 7f1daf90ca | |||
| bb0e104b7e | |||
| c0b8dec520 | |||
| b8da973a81 | |||
| a46cfd1888 | |||
| f08768b887 | |||
| 199d0065e1 | |||
| 781061b483 | |||
| 132f8e8da6 |
@@ -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"],
|
||||
|
||||
@@ -75,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) }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
assets/vendor/
|
||||
deps/
|
||||
_build/
|
||||
@@ -71,8 +71,8 @@ If it doesn't work for your use case, please make a feature request! You can als
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="priv/static/images/app-form-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
||||
<img src="priv/static/images/app-screenshot.png" alt="Pinchflat screenshot" width="700" />
|
||||
<img src="priv/static/images/app-form-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
|
||||
<img src="priv/static/images/app-screenshot.jpg" alt="Pinchflat screenshot" width="700" />
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -102,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-4
@@ -1,15 +1,13 @@
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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]
|
||||
|
||||
@@ -203,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
|
||||
|
||||
@@ -223,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,12 +142,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]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@ 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
|
||||
@@ -124,6 +124,21 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts to determine the season and episode number from a media filepath.
|
||||
|
||||
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
|
||||
Path.join([
|
||||
metadata_directory_for(database_record),
|
||||
|
||||
@@ -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?!
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
|
||||
<episodedetails>
|
||||
@@ -49,8 +54,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
||||
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
|
||||
<plot>#{safe(metadata["description"])}</plot>
|
||||
<aired>#{safe(upload_date)}</aired>
|
||||
<season>#{safe(upload_date.year)}</season>
|
||||
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
||||
<season>#{safe(season)}</season>
|
||||
<episode>#{episode}</episode>
|
||||
<genre>YouTube</genre>
|
||||
</episodedetails>
|
||||
"""
|
||||
@@ -67,4 +72,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
||||
</tvshow>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,6 +112,7 @@ 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 }}")
|
||||
@@ -149,6 +150,15 @@ defmodule Pinchflat.Sources.Source do
|
||||
~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
|
||||
|
||||
@@ -22,7 +22,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:uploaded_at,
|
||||
:duration_seconds
|
||||
:duration_seconds,
|
||||
:playlist_index
|
||||
]
|
||||
|
||||
alias __MODULE__
|
||||
@@ -63,7 +64,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
@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)
|
||||
@@ -84,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 """
|
||||
@@ -104,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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
<li :for={{k, v} <- @iterable_attributes} class="mb-2 w-2/3">
|
||||
<strong><%= k %>:</strong>
|
||||
<code class="inline-block text-sm font-mono text-gray p-0.5 mx-0.5">
|
||||
<%= v %>
|
||||
<%= if is_binary(v) && URI.parse(v).scheme && URI.parse(v).scheme =~ "http" do %>
|
||||
<TextComponents.inline_link href={v}><%= v %></TextComponents.inline_link>
|
||||
<% else %>
|
||||
<%= v %>
|
||||
<% end %>
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -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: "<code>Season YYYY/sYYYYeMMDD</code>",
|
||||
season_by_year__episode_by_date_and_index:
|
||||
"same as the above but it handles dates better. <strong>This is the recommended option</strong>",
|
||||
static_season__episode_by_index:
|
||||
"<code>Season 1/s01eXX</code> where <code>XX</code> is the video's position in the playlist. Only recommended for playlists (not channels) that don't change",
|
||||
static_season__episode_by_date:
|
||||
"<code>Season 1/s01eYYMMDD</code>. 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 <code>YYYY-MM-DD</code>",
|
||||
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 <code>sYYYYeMMDD</code>",
|
||||
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 <code>sYYYYeMMDDII</code> where <code>II</code> 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
|
||||
|
||||
+11
-11
@@ -84,22 +84,22 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
|
||||
<.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]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section x-data="{ presets: { default: true, media_center: true, audio: false, archiving: true } }">
|
||||
<.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]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section x-data="{ presets: { default: false, media_center: false, audio: false, archiving: false } }">
|
||||
<.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]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
+14
-7
@@ -1,7 +1,7 @@
|
||||
<%!-- The heex HTML formatter is really struggling with this file - I apologize in advance --%>
|
||||
<aside>
|
||||
<h2 class="text-xl font-bold mb-2">Output Template Syntax</h2>
|
||||
<section class="ml-2 md:ml-4 mb-4 max-w-prose">
|
||||
<section class="ml-2 md:ml-4 mb-4 max-w-prose overflow-auto">
|
||||
<p>When generating an output template, you have 3 options for syntax:</p>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li>
|
||||
@@ -46,7 +46,7 @@
|
||||
</section>
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">Template Options</h2>
|
||||
<section class="ml-2 md:ml-4 mb-4">
|
||||
<section class="ml-2 md:ml-4 mb-4 overflow-auto">
|
||||
<p class="max-w-prose">
|
||||
Any single-word <code class="text-sm">yt-dlp</code>
|
||||
option
|
||||
@@ -56,16 +56,23 @@
|
||||
can be used with the curly braced liquid-style syntax.
|
||||
This is just a list of the most common options as well as some custom aliases
|
||||
</p>
|
||||
<h3 class="text-lg font-bold mb-2">Custom Aliases</h3>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li :for={{k, v} <- custom_output_template_options()}>
|
||||
<h3 class="text-lg font-bold my-2">Media Center Custom Aliases</h3>
|
||||
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||
<li :for={{k, v} <- media_center_custom_output_template_options()} class="mt-1">
|
||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
||||
<span :if={v}>- <%= v %></span>
|
||||
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-lg font-bold mb-2">Other Custom Aliases</h3>
|
||||
<ul class="list-disc list-inside mb-4 ml-2 md:ml-5 max-w-prose">
|
||||
<li :for={{k, v} <- other_custom_output_template_options()} class="mt-1">
|
||||
<.inline_code>{{ <%= k %> }}</.inline_code>
|
||||
<span :if={v}>- <%= html_escape({:safe, v}) %></span>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-lg font-bold mb-2">Common Options</h3>
|
||||
<ul class="list-disc list-inside ml-2 md:ml-5">
|
||||
<li :for={opt <- common_output_template_options()}>
|
||||
<li :for={opt <- common_output_template_options()} class="mt-1">
|
||||
<.inline_code>{{ <%= opt %> }}</.inline_code>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -41,13 +41,30 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
render(conn, :index, sources: Repo.all(source_query))
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Sources.change_source(%Source{})
|
||||
def new(conn, params) do
|
||||
# This lets me preload the settings from another source for more efficient creation
|
||||
cs_struct =
|
||||
case to_string(params["template_id"]) do
|
||||
"" -> %Source{}
|
||||
template_id -> Repo.get(Source, template_id) || %Source{}
|
||||
end
|
||||
|
||||
render(conn, :new,
|
||||
changeset: changeset,
|
||||
media_profiles: media_profiles(),
|
||||
layout: get_onboarding_layout()
|
||||
layout: get_onboarding_layout(),
|
||||
# Most of these don't actually _need_ to be nullified at this point,
|
||||
# but if I don't do it now I know it'll bite me
|
||||
changeset:
|
||||
Sources.change_source(%Source{
|
||||
cs_struct
|
||||
| id: nil,
|
||||
uuid: nil,
|
||||
custom_name: nil,
|
||||
collection_name: nil,
|
||||
collection_id: nil,
|
||||
collection_type: nil,
|
||||
original_url: nil
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||
attr :changeset, Ecto.Changeset, required: true
|
||||
attr :action, :string, required: true
|
||||
attr :media_profiles, :list, required: true
|
||||
attr :method, :string, required: true
|
||||
|
||||
def source_form(assigns)
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link href={~p"/sources/new?template_id=#{@source}"} method="get">
|
||||
Use as Template
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<div class="h-px w-full bg-bodydark2"></div>
|
||||
</:option>
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources/#{@source}"} />
|
||||
<.source_form
|
||||
changeset={@changeset}
|
||||
media_profiles={@media_profiles}
|
||||
action={~p"/sources/#{@source}"}
|
||||
method="patch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full">
|
||||
<div class="flex flex-col gap-10">
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} />
|
||||
<.source_form changeset={@changeset} media_profiles={@media_profiles} action={~p"/sources"} method="post" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={@action}
|
||||
method={@method}
|
||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||
>
|
||||
@@ -10,7 +11,7 @@
|
||||
</.error>
|
||||
|
||||
<section x-data="{ mediaProfileId: null }">
|
||||
<section class="flex justify-between items-center mt-8">
|
||||
<section class="flex justify-between items-center mt-4">
|
||||
<h3 class=" text-2xl text-black dark:text-white">
|
||||
General Options
|
||||
</h3>
|
||||
@@ -19,6 +20,14 @@
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<.input
|
||||
field={f[:original_url]}
|
||||
type="text"
|
||||
label="Source URL"
|
||||
help="URL of a channel or playlist (required)"
|
||||
x-init="$el.focus()"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={f[:custom_name]}
|
||||
type="text"
|
||||
@@ -26,8 +35,6 @@
|
||||
help="Does not impact indexing or downloading. Will be inferred from the source if left blank"
|
||||
/>
|
||||
|
||||
<.input field={f[:original_url]} type="text" label="Source URL" help="URL of a channel or playlist (required)" />
|
||||
|
||||
<.input
|
||||
field={f[:media_profile_id]}
|
||||
options={Enum.map(@media_profiles, &{&1.name, &1.id})}
|
||||
|
||||
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "2024.6.10",
|
||||
elixir: "~> 1.16",
|
||||
version: "2024.7.18",
|
||||
elixir: "~> 1.17",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
@@ -71,7 +71,7 @@ defmodule Pinchflat.MixProject do
|
||||
{:nimble_parsec, "~> 1.4"},
|
||||
{:timex, "~> 3.0"},
|
||||
{:mox, "~> 1.0", only: :test},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:credo, "~> 1.7.7", only: [:dev, :test], runtime: false},
|
||||
{:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:ex_check, "~> 0.14.0", only: [:dev, :test], runtime: false},
|
||||
{:faker, "~> 0.17", only: :test},
|
||||
@@ -87,6 +87,8 @@ defmodule Pinchflat.MixProject do
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
check: "check --config=tooling/.check.exs",
|
||||
credo: "credo --config-file=tooling/.credo.exs",
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"},
|
||||
"credo": {:hex, :credo, "1.7.3", "05bb11eaf2f2b8db370ecaa6a6bda2ec49b2acd5e0418bc106b73b07128c0436", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "35ea675a094c934c22fb1dca3696f3c31f2728ae6ef5a53b5d648c11180a4535"},
|
||||
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
|
||||
"credo_naming": {:hex, :credo_naming, "2.1.0", "d44ad58890d4db552e141ce64756a74ac1573665af766d1ac64931aa90d47744", [:make, :mix], [{:credo, "~> 1.6", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "830e23b3fba972e2fccec49c0c089fe78c1e64bc16782a2682d78082351a2909"},
|
||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||
|
||||
+3
-1
@@ -5,7 +5,9 @@
|
||||
"sqleton": "^2.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
|
||||
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db",
|
||||
"lint:check": "prettier . --check --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore",
|
||||
"lint:fix": "prettier . --write --config=tooling/.prettierrc.js --ignore-path=tooling/.prettierignore --ignore-path=.gitignore"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 424 KiB |
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddPlaylistIndexToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :playlist_index, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
end
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB |
@@ -93,15 +93,23 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
|
||||
test "includes :write_auto_subs option when specified", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
|
||||
media_item_1 = update_media_profile_attribute(media_item, %{download_subs: true, download_auto_subs: true})
|
||||
media_item_2 = update_media_profile_attribute(media_item, %{embed_subs: true, download_auto_subs: true})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
assert {:ok, res_1} = DownloadOptionBuilder.build(media_item_1)
|
||||
assert {:ok, res_2} = DownloadOptionBuilder.build(media_item_2)
|
||||
|
||||
assert :write_auto_subs in res
|
||||
assert :write_auto_subs in res_1
|
||||
assert :write_auto_subs in res_2
|
||||
end
|
||||
|
||||
test "doesn't include :write_auto_subs option when download_subs is false", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{download_subs: false, download_auto_subs: true})
|
||||
test "doesn't include :write_auto_subs option when download_subs and embed_subs is false", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{
|
||||
download_subs: false,
|
||||
embed_subs: false,
|
||||
download_auto_subs: true
|
||||
})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
|
||||
@@ -689,7 +689,24 @@ defmodule Pinchflat.MediaTest do
|
||||
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
|
||||
|
||||
assert media_item_1.id == media_item_2.id
|
||||
assert media_item_2.title == different_attrs.title
|
||||
assert Repo.reload(media_item_2).title == different_attrs.title
|
||||
end
|
||||
|
||||
test "doesn't update fields like playlist_index" do
|
||||
source = source_fixture()
|
||||
|
||||
media_attrs =
|
||||
media_attributes_return_fixture()
|
||||
|> Phoenix.json_library().decode!()
|
||||
|> Map.put("playlist_index", 1)
|
||||
|> YtDlpMedia.response_to_struct()
|
||||
|
||||
different_attrs = %YtDlpMedia{media_attrs | playlist_index: 9999}
|
||||
|
||||
assert {:ok, %MediaItem{} = _media_item_1} = Media.create_media_item_from_backend_attrs(source, media_attrs)
|
||||
assert {:ok, %MediaItem{} = media_item_2} = Media.create_media_item_from_backend_attrs(source, different_attrs)
|
||||
|
||||
assert Repo.reload(media_item_2).playlist_index == media_attrs.playlist_index
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
||||
{:ok, %{media_item: media_item}}
|
||||
end
|
||||
|
||||
describe "metadata_directory_for/1" do
|
||||
test "returns the metadata directory for the given record", %{media_item: media_item} do
|
||||
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
||||
|
||||
metadata_directory = Helpers.metadata_directory_for(media_item)
|
||||
|
||||
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "compress_and_store_metadata_for/2" do
|
||||
test "returns the filepath", %{media_item: media_item} do
|
||||
metadata_map = %{"foo" => "bar"}
|
||||
@@ -32,7 +42,7 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
||||
metadata_map = %{"foo" => "bar"}
|
||||
|
||||
filepath = Helpers.compress_and_store_metadata_for(media_item, metadata_map)
|
||||
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :all))
|
||||
{:ok, json} = File.open(filepath, [:read, :compressed], &IO.read(&1, :eof))
|
||||
|
||||
assert json == Phoenix.json_library().encode!(metadata_map)
|
||||
end
|
||||
@@ -142,13 +152,20 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "metadata_directory_for/1" do
|
||||
test "returns the metadata directory for the given record", %{media_item: media_item} do
|
||||
base_metadata_directory = Application.get_env(:pinchflat, :metadata_directory)
|
||||
describe "season_and_episode_from_media_filepath/1" do
|
||||
test "returns a season and episode if one can be determined" do
|
||||
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2 - test.mp4")
|
||||
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/S1E2 - test.mp4")
|
||||
assert {:ok, {"001", "002"}} = Helpers.season_and_episode_from_media_filepath("/foo/s001e002 - test.mp4")
|
||||
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/s1e2bar - test.mp4")
|
||||
assert {:ok, {"1", "2"}} = Helpers.season_and_episode_from_media_filepath("/foo/bar s1e2 - test.mp4")
|
||||
end
|
||||
|
||||
metadata_directory = Helpers.metadata_directory_for(media_item)
|
||||
|
||||
assert metadata_directory == Path.join([base_metadata_directory, "media_items", "#{media_item.id}"])
|
||||
test "returns an error if a season and episode can't be determined" do
|
||||
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/test.mp4")
|
||||
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1 - test.mp4")
|
||||
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1e - test.mp4")
|
||||
assert {:error, :indeterminable} = Helpers.season_and_episode_from_media_filepath("/foo/s1etest.mp4")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
setup do
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:json)
|
||||
filepath = FilesystemUtils.generate_metadata_tmpfile(:nfo)
|
||||
|
||||
on_exit(fn -> File.rm!(filepath) end)
|
||||
|
||||
@@ -45,6 +45,43 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
||||
|
||||
assert String.contains?(nfo, "hello' & <world>")
|
||||
end
|
||||
|
||||
test "uses the season and episode number from the filepath if it can be determined" do
|
||||
metadata = %{
|
||||
"title" => "title",
|
||||
"uploader" => "uploader",
|
||||
"id" => "id",
|
||||
"description" => "description",
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
tmpfile_directory = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
filepath = Path.join([tmpfile_directory, "foo/s0123e456.nfo"])
|
||||
|
||||
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||
nfo = File.read!(result)
|
||||
|
||||
assert String.contains?(nfo, "<season>0123</season>")
|
||||
assert String.contains?(nfo, "<episode>456</episode>")
|
||||
|
||||
File.rm!(filepath)
|
||||
end
|
||||
|
||||
test "uses the upload date if the season and episode number can't be determined", %{filepath: filepath} do
|
||||
metadata = %{
|
||||
"title" => "title",
|
||||
"uploader" => "uploader",
|
||||
"id" => "id",
|
||||
"description" => "description",
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||
nfo = File.read!(result)
|
||||
|
||||
assert String.contains?(nfo, "<season>2021</season>")
|
||||
assert String.contains?(nfo, "<episode>0101</episode>")
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_and_store_for_source/2" do
|
||||
|
||||
@@ -75,4 +75,51 @@ defmodule Pinchflat.Metadata.SourceImageParserTest do
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "store_source_images/2 when testing fallbacks" do
|
||||
test "uses the entries list for a fallback poster if needed" do
|
||||
metadata = %{
|
||||
"thumbnails" => [],
|
||||
"entries" => [
|
||||
%{
|
||||
"thumbnails" => [%{"filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expected = %{
|
||||
poster_filepath: "#{@base_dir}/poster.jpg"
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
|
||||
end
|
||||
|
||||
test "doesn't blow up if the entries list doesn't have any suitable thumbnails" do
|
||||
metadata = %{
|
||||
"thumbnails" => [],
|
||||
"entries" => [
|
||||
%{"thumbnails" => [%{"id" => "1"}]}
|
||||
]
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
|
||||
test "doesn't use the entries list if it's empty" do
|
||||
metadata = %{
|
||||
"thumbnails" => [],
|
||||
"entries" => []
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
|
||||
test "doesn't use the entries list if it's not present" do
|
||||
metadata = %{
|
||||
"thumbnails" => []
|
||||
}
|
||||
|
||||
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -143,7 +143,9 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
||||
|
||||
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||
|
||||
_url, _opts, ot when ot == @metadata_ot ->
|
||||
_url, opts, ot when ot == @metadata_ot ->
|
||||
assert {:convert_thumbnails, "jpg"} in opts
|
||||
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
@@ -164,6 +166,42 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
||||
Sources.delete_source(source, delete_files: true)
|
||||
end
|
||||
|
||||
test "calls one set of yt-dlp metadata opts for channels" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
{:ok, source_details_return_fixture()}
|
||||
|
||||
_url, opts, ot when ot == @metadata_ot ->
|
||||
assert {:playlist_items, 0} in opts
|
||||
assert :write_all_thumbnails in opts
|
||||
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_source_images: true})
|
||||
source = source_fixture(media_profile_id: profile.id, collection_type: :channel)
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
end
|
||||
|
||||
test "calls another set of yt-dlp metadata opts for playlists" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
{:ok, source_details_return_fixture()}
|
||||
|
||||
_url, opts, ot when ot == @metadata_ot ->
|
||||
assert {:playlist_items, 1} in opts
|
||||
assert :write_thumbnail in opts
|
||||
|
||||
{:ok, render_metadata(:channel_source_metadata)}
|
||||
end)
|
||||
|
||||
profile = media_profile_fixture(%{download_source_images: true})
|
||||
source = source_fixture(media_profile_id: profile.id, collection_type: :playlist)
|
||||
|
||||
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||
end
|
||||
|
||||
test "does not store source images if the profile is not set to" do
|
||||
stub(YtDlpRunnerMock, :run, fn
|
||||
_url, _opts, ot when ot == @source_details_ot ->
|
||||
|
||||
@@ -659,6 +659,28 @@ defmodule Pinchflat.SourcesTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_source/3 when testing regex validation" do
|
||||
test "succeeds when a valid regex is provided" do
|
||||
source = source_fixture()
|
||||
|
||||
assert %{errors: []} = Sources.change_source(source, %{title_filter_regex: "(?i)^How to Bike$"})
|
||||
end
|
||||
|
||||
test "succeeds when a regex is set back to nil" do
|
||||
source = source_fixture(%{title_filter_regex: "(?i)^How to Bike$"})
|
||||
|
||||
assert %{errors: []} = Sources.change_source(source, %{title_filter_regex: nil})
|
||||
end
|
||||
|
||||
test "fails when an invalid regex is provided" do
|
||||
source = source_fixture()
|
||||
|
||||
changeset = Sources.change_source(source, %{title_filter_regex: "*FOO"})
|
||||
|
||||
assert "is invalid" in errors_on(changeset).title_filter_regex
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_source/3 when testing original_url validation" do
|
||||
test "succeeds when an original URL is valid" do
|
||||
source = source_fixture()
|
||||
|
||||
@@ -129,7 +129,7 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
||||
|
||||
test "it passes the expected args to the backend runner" do
|
||||
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
|
||||
assert opts == [playlist_items: 0]
|
||||
assert opts == [:skip_download, playlist_items: 0]
|
||||
assert ot == "playlist:%()j"
|
||||
|
||||
{:ok, "{}"}
|
||||
@@ -152,12 +152,18 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
||||
|
||||
test "allows you to pass additional opts" do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
|
||||
assert opts == [playlist_items: 0, real_opt: :yup]
|
||||
assert opts == [:skip_download, playlist_items: 1, real_opt: :yup]
|
||||
|
||||
{:ok, "{}"}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
|
||||
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, playlist_items: 1, real_opt: :yup)
|
||||
end
|
||||
|
||||
test "blows up if you pass addl opts but don't pass playlist items" do
|
||||
assert_raise KeyError, fn ->
|
||||
MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -110,8 +110,10 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
|
||||
describe "indexing_output_template/0" do
|
||||
test "contains all the greatest hits" do
|
||||
assert "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j" ==
|
||||
Media.indexing_output_template()
|
||||
attrs = ~w(id title was_live webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a
|
||||
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
|
||||
|
||||
assert formatted_attrs == Media.indexing_output_template()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -126,7 +128,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => 60,
|
||||
"upload_date" => "20210101",
|
||||
"timestamp" => 1_600_000_000
|
||||
"timestamp" => 1_600_000_000,
|
||||
"playlist_index" => 1
|
||||
}
|
||||
|
||||
assert %Media{
|
||||
@@ -137,7 +140,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
livestream: false,
|
||||
short_form_content: false,
|
||||
uploaded_at: ~U[2020-09-13 12:26:40Z],
|
||||
duration_seconds: 60
|
||||
duration_seconds: 60,
|
||||
playlist_index: 1
|
||||
} == Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
@@ -217,6 +221,17 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
|
||||
assert %Media{livestream: false} = Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
test "doesn't blow up if playlist_index is missing" do
|
||||
response = %{
|
||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => nil,
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
assert %Media{playlist_index: 0} = Media.response_to_struct(response)
|
||||
end
|
||||
end
|
||||
|
||||
describe "response_to_struct/1 when testing uploaded_at" do
|
||||
|
||||
@@ -50,6 +50,15 @@ defmodule PinchflatWeb.SourceControllerTest do
|
||||
|
||||
refute html_response(conn, 200) =~ "MENU"
|
||||
end
|
||||
|
||||
test "preloads some attributes when using a template", %{conn: conn} do
|
||||
source = source_fixture(custom_name: "My first source", download_cutoff_date: "2021-01-01")
|
||||
|
||||
conn = get(conn, ~p"/sources/new", %{"template_id" => source.id})
|
||||
assert html_response(conn, 200) =~ "New Source"
|
||||
assert html_response(conn, 200) =~ "2021-01-01"
|
||||
refute html_response(conn, 200) =~ source.custom_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "create source" do
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{:compiler, env: %{"MIX_ENV" => "test"}},
|
||||
{:formatter, env: %{"MIX_ENV" => "test"}},
|
||||
{:sobelow, "mix sobelow --config"},
|
||||
{:prettier_formatting, "yarn run prettier . --check", fix: "yarn run prettier . --write"},
|
||||
{:prettier_formatting, "yarn run lint:check", fix: "yarn run lint:fix"},
|
||||
{:npm_test, false}
|
||||
|
||||
## curated tools may be disabled (e.g. the check for compilation warnings)
|
||||
@@ -0,0 +1 @@
|
||||
../assets/vendor/
|
||||
Reference in New Issue
Block a user