Compare commits

..

21 Commits

Author SHA1 Message Date
Kieran Eglin a3ebf59b29 Bumped version 2024-07-18 11:09:28 -07:00
Kieran 8dd8e49051 [Dev] Fix CI caching (#325)
* Consolidated dockerfile commands

* Added teardown step

* FIxed typo

* Got rid of old caching action

* Added buildx

* Added steps for compiling test ENV to dockerfile

* Changed the order of things

* Added env vars

* more env vars

* Removed step

* No compile flag

* fetch quest

* Removed other thing

* volumes

* Rolling back some other changes

* more consolidation

* updated checkout runner
2024-07-17 14:21:01 -07:00
Kieran 029981b7ee [Enhancement] Support auto-generated subs when embedding subs in media file (#324)
* Allowed using auto subs when embedding

* Updated profile form
2024-07-17 10:33:50 -07:00
Kieran d423e4486f Added padding to playlist_index output template (#321) 2024-07-16 15:30:27 -07:00
Kieran 4c7a63c970 [Bugfix] Determine NFO season and episode from filepath (#320)
* Updated NFO generator to determine S/E from filepath

* Tests
2024-07-16 15:16:24 -07:00
Kieran 4822127e3f Added date-based outtmpl helper for static playlists (#319) 2024-07-16 14:06:54 -07:00
Kieran Eglin 4f25878f2f Bumped version 2024-07-16 10:00:09 -07:00
Kieran aec56e3a03 Added some improved output template helpers (#318) 2024-07-16 09:41:43 -07:00
Kieran e06e050f95 [Enhancement] Capture playlist_index for use with output templates (#315)
* Started capturing playlist_index on indexing pass

* Added playlist_index as a media item field

* Added playlist index to output variable templates

* Improved the way playlist_indexes are rejected on update

* Updated docs

* Undid unneeded changes
2024-07-15 16:06:13 -07:00
Kieran 5a10015634 [Enhancement] Download image when using playlists with media center apps (#313)
* [WIP] started adding calls for downloading posters for playlists

* Updated source image parser to work with playlists
2024-07-15 12:04:57 -07:00
Dan 0d5a41fd35 [Enhancement] Render URL attributes as a link (#299)
* Render URLs as links when listing items from a map

* Applies suggestions from MR feedback
2024-07-15 08:59:02 -07:00
Kieran 8f91c4e6a2 [Enhancement] Improve flow for adding many sources at once (#306)
* Added mechamism for using existing sources as a reference for adding new sources

* Added test
2024-07-12 08:53:26 -07:00
Kieran 7f1daf90ca Add date to 'prod' logger (#305) 2024-07-11 10:19:14 -07:00
Kieran bb0e104b7e Runs yarn install at project level on app boot (#304) 2024-07-11 09:29:53 -07:00
Kieran Eglin c0b8dec520 no-op change to force a rebuild with new yt-dlp version 2024-07-11 08:30:33 -07:00
Kieran Eglin b8da973a81 Bumped version 2024-06-25 08:19:06 -07:00
Kieran a46cfd1888 [Bugfix] Check for regex errors before saving a source (#296)
* Added error if provided regex is invalid

* improved test
2024-06-20 12:48:52 -07:00
Kieran Eglin f08768b887 Updated formatter 2024-06-20 11:33:49 -07:00
Kieran Eglin 199d0065e1 Updated screenshots 2024-06-14 09:23:40 -07:00
Kieran 781061b483 [Dev] Reduce config file clutter (#290)
* Added local tooling config files to the tooling/ directory

* Moved as many docker-related files as possible

* Updated dockerfile path

* Updated the correct dockerfile instead how about

* Off day - jeez louise

* Juggled around some more dockerfiles

* more dockerfile

* More docker tests

* it's docker time

* One more shot
2024-06-13 16:21:09 -07:00
Kieran 132f8e8da6 [Dev] Upgrade to Elixir 1.17.0 (#289)
* upgraded to Elixir 1.17.0

* Addressed deprecation warnings

* Updated Credo
2024-06-13 10:05:03 -07:00
55 changed files with 572 additions and 171 deletions
+2
View File
@@ -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"],
+1 -1
View File
@@ -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) }}
+15 -12
View File
@@ -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: |
-3
View File
@@ -1,3 +0,0 @@
assets/vendor/
deps/
_build/
+3 -3
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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:
+28 -33
View 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
+6 -1
View File
@@ -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]
)
+3
View File
@@ -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),
+21 -9
View File
@@ -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
+48 -18
View File
@@ -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
+10
View File
@@ -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
+8 -4
View File
@@ -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
+13 -2
View File
@@ -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
@@ -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>
@@ -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})}
+5 -3
View File
@@ -4,8 +4,8 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "2024.6.10",
elixir: "~> 1.16",
version: "2024.7.19-1",
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"],
+1 -1
View File
@@ -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
View File
@@ -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
}
BIN
View File
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)
+18 -1
View File
@@ -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
+38 -1
View File
@@ -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&#39; &amp; &lt;world&gt;")
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 ->
+22
View File
@@ -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
+19 -4
View File
@@ -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
+1 -1
View File
@@ -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)
View File
+1
View File
@@ -0,0 +1 @@
../assets/vendor/