Compare commits

...

8 Commits

Author SHA1 Message Date
Kieran Eglin f6d8e15670 Bumped version 2024-03-20 14:48:58 -07:00
Kieran 2906ff1b30 Update logo (#100)
* Placeholder commit

* Added new logo to app

* added logo to header of small screens

* Updated favicon

* Added logo to README

* Updated README
2024-03-20 14:34:49 -07:00
Kieran 0582a7bfd5 Misc indexing improvements (#99)
* increased file follower timeout to 10 minutes

* Removed unique index across a source's collection and its media profile

* Ensure source gets updating during slow indexing
2024-03-20 11:34:33 -07:00
Kieran 3db2e8190f Source title regex filtering (#98)
* Added a basic/advanced mode to source form

* Added regex field and UI to source form

* Implemented title regex filtering
2024-03-20 10:51:14 -07:00
Kieran Eglin 087c9c2d0f Bumped version; added link to donation receipts 2024-03-19 14:32:59 -07:00
Kieran c046abf36f Improve index tables and improved consistency (#97)
* Simplifies index pages

* Improved UX of more pages
2024-03-19 14:03:02 -07:00
Kieran 70dd95211f Download images for sources (#96)
* [WIP] hooked up photo downloading to source metadata worker

* Added new attributes for source images

* Added option to profile form
2024-03-19 13:34:01 -07:00
Kieran 8491f8b317 Source NFO downloads (#95)
* Hooked up series directory finding to source metadata runner

* Fixed aired NFO tag for episodes

* Updated MI NFO builder to take in a filepath

* Hooked up NFO generation to the source worker

* Added NFO controls to form

* Improved the way the source metadata worker updates the source

* Consolidated NFO selection options in media profile instead of source
2024-03-18 14:27:28 -07:00
103 changed files with 1275 additions and 216 deletions
+21 -1
View File
@@ -1,7 +1,27 @@
# Pinchflat (Alpha)
<p align="center">
<img
src="priv/static/images/originals/logo-white-wordmark-with-background.png"
alt="Pinchflat Logo by @hernandito"
width="700"
/>
</p>
<p align="center">
<sup>
<em>logo by <a href="https://github.com/hernandito" target="_blank">@hernandito</a></em>
</sup>
</p>
# Your next YouTube media manager
## Disclaimer
This is alpha software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges.
## EFF Donation Receipts
A portion of all donations to Pinchflat will be donated to the Electronic Frontier Foundation. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
## What is Pinchflat?
TODO: expand on this.
+15
View File
@@ -25,6 +25,21 @@ config :pinchflat,
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
arch_string = to_string(:erlang.system_info(:system_architecture))
system_arch =
cond do
String.contains?(arch_string, "arm") -> "arm"
String.contains?(arch_string, "aarch") -> "arm"
String.contains?(arch_string, "x86") -> "x86"
true -> "unknown"
end
config :pinchflat, Pinchflat.Repo,
load_extensions: [
Path.join([:code.priv_dir(:pinchflat), "repo", "extensions", "sqlean-linux-#{system_arch}", "sqlean"])
]
if config_env() == :prod do
config_path =
System.get_env("CONFIG_PATH") ||
@@ -3,6 +3,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
Builds the options for yt-dlp to download media based on the given media profile.
"""
alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.OutputPathBuilder
@@ -26,6 +27,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
{:ok, built_options}
end
@doc """
Builds the output path for yt-dlp to download media based on the given source's
media profile.
Returns binary()
"""
def build_output_path_for(%Source{} = source_with_preloads) do
output_path_template = source_with_preloads.media_profile.output_path_template
build_output_path(output_path_template, source_with_preloads)
end
defp default_options do
[:no_progress, :windows_filenames]
end
@@ -104,23 +117,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end
defp output_options(media_item_with_preloads) do
output_path_template = media_item_with_preloads.source.media_profile.output_path_template
[
output: build_output_path(output_path_template, media_item_with_preloads)
output: build_output_path_for(media_item_with_preloads.source)
]
end
defp build_output_path(string, media_item_with_preloads) do
additional_options_map = output_options_map(media_item_with_preloads)
defp build_output_path(string, source) do
additional_options_map = output_options_map(source)
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
Path.join(base_directory(), output_path)
end
defp output_options_map(media_item_with_preloads) do
source = media_item_with_preloads.source
defp output_options_map(source) do
%{
"source_custom_name" => source.custom_name,
"source_collection_type" => source.collection_type
@@ -137,7 +146,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|> String.split(~r{\.}, include_captures: true)
|> List.insert_at(-3, "-thumb")
|> Enum.join()
|> build_output_path(media_item_with_preloads)
|> build_output_path(media_item_with_preloads.source)
end
defp base_directory do
@@ -56,7 +56,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
defp determine_nfo_filepath(media_item, parsed_json) do
if media_item.source.media_profile.download_nfo do
NfoBuilder.build_and_store_for_media_item(parsed_json)
filepath = Path.rootname(parsed_json["filepath"]) <> ".nfo"
NfoBuilder.build_and_store_for_media_item(filepath, parsed_json)
else
nil
end
@@ -34,6 +34,19 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
File.write!(filepath, content, modes)
end
@doc """
Copies a file from source to destination, creating directories as needed.
Returns :ok | raises on error
"""
def cp_p!(source, destination) do
destination
|> Path.dirname()
|> File.mkdir_p!()
File.cp!(source, destination)
end
@doc """
Fetches the file size of a media item and saves it to the database.
+9
View File
@@ -66,6 +66,7 @@ defmodule Pinchflat.Media do
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile))
|> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit)
|> Repo.all()
end
@@ -247,6 +248,14 @@ defmodule Pinchflat.Media do
end
end
defp maybe_apply_title_regex(source) do
if source.title_filter_regex do
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
else
dynamic(true)
end
end
defp build_format_clauses(media_profile) do
mapped_struct = Map.from_struct(media_profile)
@@ -63,6 +63,43 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
Date.from_iso8601!("#{year}-#{month}-#{day}")
end
@doc """
Attempts to determine the series directory from a media filepath.
The series directory is the "root" directory for a given source
which should contain all the season-level folders of that source.
Used for determining where to store things like NFO data and banners
for media center apps. Not useful without a media center app.
Returns {:ok, binary()} | {:error, :indeterminable}
"""
def series_directory_from_media_filepath(media_filepath) do
# Matches "s" or "season" (case-insensitive)
# followed by an optional non-word character (. or _ or <space>, etc)
# followed by at least one digit
# followed immediately by the end of the string
# Example matches: s1, s.1, s01 season 1, Season.01, Season_1, Season 1, Season1
# Example non-matches: s01e01, season, series 1,
season_regex = ~r/^s(eason)?(\W|_)?\d{1,}$/i
{series_directory, found_series_directory} =
media_filepath
|> Path.split()
|> Enum.reduce_while({[], false}, fn part, {directory_acc, _} ->
if String.match?(part, season_regex) do
{:halt, {directory_acc, true}}
else
{:cont, {directory_acc ++ [part], false}}
end
end)
if found_series_directory do
{:ok, Path.join(series_directory)}
else
{:error, :indeterminable}
end
end
defp fetch_thumbnail_from_url(url) do
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
{:ok, body} = http_client.get(url, [], body_format: :binary)
+29 -5
View File
@@ -9,13 +9,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
@doc """
Builds an NFO file for a media item (read: single "episode") and
stores it in the same directory as the media file. Has the same name
as the media file, but with a .nfo extension.
stores it at the specified location.
Returns the filepath of the NFO file.
"""
def build_and_store_for_media_item(metadata) do
filepath = Path.rootname(metadata["filepath"]) <> ".nfo"
def build_and_store_for_media_item(filepath, metadata) do
nfo = build_for_media_item(metadata)
FilesystemHelpers.write_p!(filepath, nfo)
@@ -23,6 +21,20 @@ defmodule Pinchflat.Metadata.NfoBuilder do
filepath
end
@doc """
Builds an NFO file for a souce and stores it at the specified location.
Technically works for playlists, but it's really made for channels.
Returns the filepath of the NFO file.
"""
def build_and_store_for_source(filepath, metadata) do
nfo = build_for_source(metadata)
FilesystemHelpers.write_p!(filepath, nfo)
filepath
end
defp build_for_media_item(metadata) do
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
@@ -34,11 +46,23 @@ defmodule Pinchflat.Metadata.NfoBuilder do
<showtitle>#{metadata["uploader"]}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<plot>#{metadata["description"]}</plot>
<premiered>#{upload_date}</premiered>
<aired>#{upload_date}</aired>
<season>#{upload_date.year}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre>
</episodedetails>
"""
end
defp build_for_source(metadata) do
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<tvshow>
<title>#{metadata["title"]}</title>
<plot>#{metadata["description"]}</plot>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<genre>YouTube</genre>
</tvshow>
"""
end
end
@@ -0,0 +1,69 @@
defmodule Pinchflat.Metadata.SourceImageParser do
@moduledoc """
Functions for parsing and storing source images.
"""
alias Pinchflat.Filesystem.FilesystemHelpers
@doc """
Given a base directory and source metadata, look for the appropriate images
and move them to the base directory. Returns a map of image types and their
filepaths (specifically for a source). If a given image type is not found,
the key will not be present in the map.
The metadata is expected to contain the `filepath` key for images, implying
that the images have already been downloaded by yt-dlp. This method will NOT
download images from the internet - it just copys them around.
Returns a map with the possible keys :poster_filepath, :fanart_filepath, and
:banner_filepath.
"""
def store_source_images(base_directory, source_metadata) do
(source_metadata["thumbnails"] || [])
|> Enum.filter(&(&1["filepath"] != nil))
|> select_useful_images()
|> Enum.map(&move_image(&1, base_directory))
|> Enum.into(%{})
end
defp select_useful_images(images) do
labelled_images =
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
end
end)
labelled_images
|> Enum.concat([{:banner, :banner_filepath, determine_best_banner(images)}])
|> Enum.filter(fn {_, _, tmp_filepath} -> tmp_filepath end)
end
defp determine_best_banner(images) do
best_candidate =
images
# Filter out images that don't have a width and height attribute
|> Enum.filter(&(&1["width"] && &1["height"]))
# Sort images with the highest width first
|> Enum.sort(&(&1["width"] >= &2["width"]))
# Find the first image where the ratio of width to height is greater than 3
|> Enum.find(&(&1["width"] / &1["height"] > 3))
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}"])
FilesystemHelpers.cp_p!(tmp_filepath, final_filepath)
{source_attr_name, final_filepath}
end
end
@@ -4,10 +4,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
use Oban.Worker,
queue: :remote_metadata,
tags: ["media_source", "source_metadata", "remote_metadata"],
max_attempts: 1,
# This is the only thing stopping this job from calling itself
# in an infinite loop.
unique: [period: 600]
max_attempts: 3
require Logger
@@ -15,8 +12,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
alias Pinchflat.Repo
alias Pinchflat.Tasks
alias Pinchflat.Sources
alias Pinchflat.Utils.StringUtils
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceImageParser
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Downloading.DownloadOptionBuilder
@doc """
Starts the source metadata storage worker and creates a task for the source.
@@ -30,27 +31,81 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
end
@doc """
Fetches and stores metadata for a source in the secret metadata location.
Fetches and stores various forms of metadata for a source:
- JSON metadata for internal use
- The series directory for the source
- The NFO file for the source (if specified)
- Downloads and stores source images (if specified)
The worker is kicked off after a source is inserted/updated - this can
take an unknown amount of time so don't rely on this data being here
before, say, the first indexing or downloading task is complete.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => source_id}}) do
source = Repo.preload(Sources.get_source!(source_id), :metadata)
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url)
source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
series_directory = determine_series_directory(source)
{source_metadata, image_filepath_attrs} = fetch_source_metadata_and_images(series_directory, source)
# Since updating a source kicks this job off again, we enforce job uniqueness (above)
# to once, per source, per x minutes. This is to prevent a job from calling itself
# in an infinite loop.
Sources.update_source(source, %{
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, metadata)
}
})
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
Sources.update_source(
source,
Map.merge(
%{
series_directory: series_directory,
nfo_filepath: store_source_nfo(source, series_directory, source_metadata),
metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, source_metadata)
}
},
image_filepath_attrs
),
run_post_commit_tasks: false
)
:ok
rescue
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
end
defp fetch_source_metadata_and_images(series_directory, source) do
if source.media_profile.download_source_images && series_directory do
output_path = "#{tmp_directory()}/#{StringUtils.random_string(16)}/source_image.%(ext)S"
opts = [:write_all_thumbnails, convert_thumbnails: "jpg", output: output_path]
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, opts)
image_attrs = SourceImageParser.store_source_images(series_directory, metadata)
{metadata, image_attrs}
else
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url, [])
{metadata, %{}}
end
end
defp determine_series_directory(source) do
output_path = DownloadOptionBuilder.build_output_path_for(source)
{:ok, %{filepath: filepath}} = MediaCollection.get_source_details(source.original_url, output: output_path)
case MetadataFileHelpers.series_directory_from_media_filepath(filepath) do
{:ok, series_directory} -> series_directory
{:error, _} -> nil
end
end
defp store_source_nfo(source, series_directory, metadata) do
if source.media_profile.download_nfo && series_directory do
nfo_filepath = Path.join(series_directory, "tvshow.nfo")
NfoBuilder.build_and_store_for_source(nfo_filepath, metadata)
end
end
defp tmp_directory do
Application.get_env(:pinchflat, :tmpfile_directory)
end
end
+2
View File
@@ -17,6 +17,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
sub_langs
download_thumbnail
embed_thumbnail
download_source_images
download_metadata
embed_metadata
download_nfo
@@ -40,6 +41,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :download_thumbnail, :boolean, default: false
field :embed_thumbnail, :boolean, default: false
field :download_source_images, :boolean, default: false
field :download_metadata, :boolean, default: false
field :embed_metadata, :boolean, default: false
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
require Logger
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
@activity_timeout_ms 60_000
@activity_timeout_ms 600_000
# Client API
@doc """
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
require Logger
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Tasks
alias Pinchflat.Sources
@@ -60,6 +61,9 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
# See the method definition below for more info on how file watchers work
# (important reading if you're not familiar with it)
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
result =
Enum.map(media_attributes, fn media_attrs ->
@@ -117,6 +121,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
end
defp create_media_item_and_enqueue_download(source, media_attrs) do
# Reload because the source may have been updated during the (long-running) indexing process
# and important settings like `download_media` may have changed.
source = Repo.reload!(source)
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
{:ok, %MediaItem{} = media_item} ->
if source.download_media && Media.pending_download?(media_item) do
+18 -1
View File
@@ -17,12 +17,18 @@ defmodule Pinchflat.Sources.Source do
collection_id
collection_type
custom_name
nfo_filepath
poster_filepath
fanart_filepath
banner_filepath
series_directory
index_frequency_minutes
fast_index
download_media
last_indexed_at
original_url
download_cutoff_date
title_filter_regex
media_profile_id
)a
@@ -59,6 +65,13 @@ defmodule Pinchflat.Sources.Source do
# Only download media items that were published after this date
field :download_cutoff_date, :date
field :original_url, :string
field :title_filter_regex, :string
field :series_directory, :string
field :nfo_filepath, :string
field :poster_filepath, :string
field :fanart_filepath, :string
field :banner_filepath, :string
belongs_to :media_profile, MediaProfile
@@ -85,7 +98,6 @@ defmodule Pinchflat.Sources.Source do
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|> validate_required(required_fields)
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|> unique_constraint([:collection_id, :media_profile_id])
end
@doc false
@@ -99,4 +111,9 @@ defmodule Pinchflat.Sources.Source do
# minutes
15
end
@doc false
def filepath_attributes do
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
end
end
+40 -20
View File
@@ -51,15 +51,19 @@ defmodule Pinchflat.Sources do
though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract.
You can pass options to control the behavior of the function:
- `run_post_commit_tasks` (default: true) - If false, the function will not
enqueue any tasks in `commit_and_handle_tasks`.
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
"""
def create_source(attrs) do
def create_source(attrs, opts \\ []) do
case change_source(%Source{}, attrs, :initial) do
%Ecto.Changeset{valid?: true} ->
%Source{}
|> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency()
|> commit_and_handle_tasks()
|> commit_and_handle_tasks(opts)
changeset ->
Repo.insert(changeset)
@@ -79,15 +83,19 @@ defmodule Pinchflat.Sources do
though we know it's going to fail so it picks up any addl. database errors
and fulfills our return contract.
You can pass options to control the behavior of the function:
- `run_post_commit_tasks` (default: true) - If false, the function will not
enqueue any tasks in `commit_and_handle_tasks`.
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
"""
def update_source(%Source{} = source, attrs) do
def update_source(%Source{} = source, attrs, opts \\ []) do
case change_source(source, attrs, :initial) do
%Ecto.Changeset{valid?: true} ->
source
|> maybe_change_source_from_url(attrs)
|> maybe_change_indexing_frequency()
|> commit_and_handle_tasks()
|> commit_and_handle_tasks(opts)
changeset ->
Repo.update(changeset)
@@ -102,7 +110,6 @@ defmodule Pinchflat.Sources do
"""
def delete_source(%Source{} = source, opts \\ []) do
delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source)
source
@@ -111,7 +118,11 @@ defmodule Pinchflat.Sources do
Media.delete_media_item(media_item, delete_files: delete_files)
end)
delete_source_metadata_files(source)
if delete_files do
delete_source_files(source)
end
delete_internal_metadata_files(source)
Repo.delete(source)
end
@@ -134,22 +145,27 @@ defmodule Pinchflat.Sources do
end
end
defp delete_source_metadata_files(source) do
defp delete_source_files(source) do
mapped_struct = Map.from_struct(source)
Source.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end
defp delete_internal_metadata_files(source) do
metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
mapped_struct = Map.from_struct(metadata)
filepaths =
SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
Enum.each(filepaths, &FilesystemHelpers.delete_file_and_remove_empty_directories/1)
SourceMetadata.filepath_attributes()
|> Enum.map(fn field -> mapped_struct[field] end)
|> Enum.filter(&is_binary/1)
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end
defp add_source_details_to_changeset(source, changeset) do
%Ecto.Changeset{changes: changes} = changeset
case MediaCollection.get_source_details(changes.original_url) do
case MediaCollection.get_source_details(changeset.changes.original_url) do
{:ok, source_details} ->
add_source_details_by_collection_type(source, changeset, source_details)
@@ -198,12 +214,16 @@ defmodule Pinchflat.Sources do
end
end
defp commit_and_handle_tasks(changeset) do
defp commit_and_handle_tasks(changeset, opts) do
run_post_commit_tasks = Keyword.get(opts, :run_post_commit_tasks, true)
case Repo.insert_or_update(changeset) do
{:ok, %Source{} = source} ->
maybe_handle_media_tasks(changeset, source)
maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
if run_post_commit_tasks do
maybe_handle_media_tasks(changeset, source)
maybe_run_indexing_task(changeset, source)
run_metadata_storage_task(source)
end
{:ok, source}
+11 -7
View File
@@ -64,14 +64,14 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_details(source_url) do
def get_source_details(source_url, addl_opts \\ []) do
# `ignore_no_formats_error` is necessary because yt-dlp will error out if
# the first video has not released yet (ie: is a premier). We don't care about
# available formats since we're just getting the source details
opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
output_template = "%(.{channel,channel_id,playlist_id,playlist_title})j"
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] ++ addl_opts
output_template = "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
with {:ok, output} <- backend_runner().run(source_url, command_opts, output_template),
{:ok, parsed_json} <- Phoenix.json_library().decode(output) do
{:ok, format_source_details(parsed_json)}
else
@@ -94,8 +94,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do
Returns {:ok, map()} | {:error, any, ...}.
"""
def get_source_metadata(source_url) do
opts = [playlist_items: 0]
def get_source_metadata(source_url, addl_opts \\ []) do
opts = [playlist_items: 0] ++ addl_opts
output_template = "playlist:%()j"
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
@@ -112,7 +112,11 @@ defmodule Pinchflat.YtDlp.MediaCollection do
channel_id: response["channel_id"],
channel_name: response["channel"],
playlist_id: response["playlist_id"],
playlist_name: response["playlist_title"]
playlist_name: response["playlist_title"],
# It's not a name, it's a path dammit!
# This actually isn't used for the inital response - it's
# used later to update a source's metadata
filepath: response["filename"]
}
end
@@ -31,6 +31,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
"""
end
@doc """
Renders a subtle link with the given href and content.
"""
attr :href, :string, required: true
slot :inner_block
def subtle_link(assigns) do
~H"""
<.link href={@href} class="underline decoration-bodydark decoration-1 hover:decoration-white">
<%= render_slot(@inner_block) %>
</.link>
"""
end
@doc """
Renders an icon as a link with the given href.
"""
@@ -1,10 +1,9 @@
<div class="flex h-screen overflow-hidden">
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center px-4 py-4 shadow-2 md:px-6 2xl:px-11">
<div class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
<div class="w-65 px-4 py-2 shadow-2 md:px-6">
<div class="flex items-center gap-2 py-2">
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
</div>
</div>
</header>
@@ -1,18 +1,18 @@
<header class="sticky top-0 z-999 flex w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none">
<div class="flex flex-grow items-center justify-between lg:justify-end px-4 py-4 shadow-2 md:px-6 2xl:px-11">
<div class="flex items-center gap-2 sm:gap-4 lg:hidden">
<div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
<section class="pr-1">
<img src={~p"/images/icon.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-10" />
</section>
<button
class="z-99999 block rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
class="z-99999 block mx-2 rounded-sm border border-stroke bg-white p-1.5 shadow-sm dark:border-strokedark dark:bg-boxdark lg:hidden"
@click.stop="sidebarVisible = !sidebarVisible"
>
<.icon name="hero-bars-3" />
</button>
<a class="hidden sm:flex items-center lg:hidden" href="/">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
</a>
</div>
<div class="bg-meta-4 rounded-md">
<div class="bg-meta-4 rounded-md w-4/6 lg:w-3/6 xl:w-2/6">
<div class="relative">
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
<.icon name="hero-magnifying-glass" />
@@ -23,7 +23,7 @@
name="q"
value={@params["q"]}
placeholder="Type to search..."
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none lg:w-125"
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
/>
</form>
</div>
@@ -1,24 +1,23 @@
<aside
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
class={[
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-60 flex-col overflow-y-hidden justify-between",
"-translate-x-full absolute left-0 top-0 z-9999 flex h-screen w-65 flex-col overflow-y-hidden justify-between",
"bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
]}
@click.outside="sidebarVisible = false"
>
<section>
<div class="flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5">
<div class="flex items-center justify-between gap-2 px-6 py-4">
<a href="/" class="flex items-center">
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
</a>
<button class="block lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
<button class="block mt-3 lg:hidden" @click.stop="sidebarVisible = !sidebarVisible">
<.icon name="hero-arrow-left" class="fill-current" />
</button>
</div>
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
<nav class="mt-3 px-4 py-4 lg:px-6">
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
<div class="flex flex-col justify-between">
<ul class="mb-6 flex flex-col gap-1.5">
@@ -31,7 +30,7 @@
</div>
</section>
<section>
<nav class="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
<nav class="px-4 py-4 lg:px-6">
<ul class="mb-6 flex flex-col gap-1.5">
<.sidebar_item
icon="hero-code-bracket"
@@ -8,7 +8,7 @@
<%= assigns[:page_title] || "Pinchflat" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-02-29"} />
<link rel="icon" type="image/x-icon" href={~p"/favicon.ico?cachebust=2024-03-20"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
@@ -30,7 +30,7 @@
method="delete"
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Files
</.button>
</.link>
@@ -2,7 +2,9 @@
<.link href={~p"/media_profiles"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Media Profile</h2>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @media_profile.name %>"
</h2>
</div>
<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">
@@ -4,7 +4,7 @@
</h2>
<nav>
<.link href={~p"/media_profiles/new"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold text-xl mx-2">+</span> New <span class="hidden sm:inline pl-1">Media Profile</span>
</.button>
</.link>
@@ -16,10 +16,9 @@
<div class="flex flex-col gap-10 min-w-max">
<.table rows={@media_profiles} table_class="text-black dark:text-white">
<:col :let={media_profile} label="Name">
<%= media_profile.name %>
</:col>
<:col :let={media_profile} label="Output Template">
<code class="text-sm"><%= media_profile.output_path_template %></code>
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
<%= media_profile.name %>
</.subtle_link>
</:col>
<:col :let={media_profile} label="Preferred Resolution">
<%= media_profile.preferred_resolution %>
@@ -162,16 +162,6 @@
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<h3 class="mt-10 text-2xl text-black dark:text-white">
Release Format Options
</h3>
@@ -181,7 +171,7 @@
field={f[:shorts_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Shorts?"
label="Include Shorts"
help="Experimental. Please report any issues on GitHub"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
@@ -192,7 +182,7 @@
field={f[:livestream_behaviour]}
options={friendly_format_type_options()}
type="select"
label="Include Livestreams?"
label="Include Livestreams"
help="Excludes media that comes from a past livestream"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/>
@@ -213,7 +203,42 @@
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Media profile</.button>
<h3 class="mt-8 text-2xl text-black dark:text-white">
Media Center Options
</h3>
<p class="text-sm mt-2 max-w-prose">
Everything in this section is experimental - please open a GitHub issue if you see something odd.
<strong>
These options only work if this Media Profile's output template is set to split media into seasons.
</strong>
Try the "Media Center" preset if you're not sure.
</p>
<section
phx-click={show_modal("upgrade-modal")}
x-data="{ presets: { default: false, media_center: true, audio: false, archiving: false } }"
>
<.input
field={f[:download_nfo]}
type="toggle"
label="Download NFO data"
label_suffix="(pro)"
help="Downloads NFO data alongside media file for use with Jellyfin, Kodi, etc."
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<section x-data="{ presets: { default: false, media_center: true, audio: false, archiving: true } }">
<.input
field={f[:download_source_images]}
type="toggle"
label="Download Series Images"
help="Downloads poster and banner images for use with Jellyfin, Kodi, etc. Only works for full channels (not playlists)"
x-init="$watch('selectedPreset', p => p && (enabled = presets[p]))"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
@@ -4,13 +4,13 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
Media Profile <span class="hidden sm:inline">#<%= @media_profile.id %></span>
<%= @media_profile.name %>
</h2>
</div>
<nav>
<.link href={~p"/media_profiles/#{@media_profile}/edit"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<.icon name="hero-pencil-square" class="mr-2" />Edit <span class="hidden sm:inline pl-1">Media Profile</span>
</.button>
</.link>
@@ -31,7 +31,7 @@
method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Profile and its Sources
</.button>
</.link>
@@ -41,7 +41,7 @@
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-full">
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Profile, Sources, and Files
</.button>
</.link>
@@ -11,7 +11,7 @@
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
<div class="mt-8">
<.link href={~p"/media_profiles/new"}>
<.button color="bg-primary" rounding="rounded-full" disabled={@media_profiles_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={@media_profiles_exist}>
<span class="font-bold mx-2">+</span> New Media Profile
</.button>
</.link>
@@ -25,7 +25,7 @@
</p>
<div class="mt-8">
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-full" disabled={not @media_profiles_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={not @media_profiles_exist}>
<span class="font-bold mx-2">+</span> New Source
</.button>
</.link>
@@ -40,7 +40,7 @@
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
<div class="mt-8">
<.link href={~p"/?onboarding=0"}>
<.button color="bg-primary" rounding="rounded-full" disabled={not @sources_exist}>
<.button color="bg-primary" rounding="rounded-lg" disabled={not @sources_exist}>
Let's Go <span class="font-bold mx-2">🚀</span>
</.button>
</.link>
@@ -1,6 +1,6 @@
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
<h2 class="text-title-md2 font-bold text-black dark:text-white">
<span class="hidden sm:inline">Search </span>Results for "<%= StringUtils.truncate(@search_term, 50) %>"
Results for "<%= StringUtils.truncate(@search_term, 50) %>"
</h2>
</div>
@@ -10,7 +10,7 @@
<%= if match?([_|_], @search_results) do %>
<.table rows={@search_results} table_class="text-black dark:text-white">
<:col :let={result} label="Title">
<%= StringUtils.truncate(result.title, 40) %>
<%= result.title %>
</:col>
<:col :let={result} label="Excerpt">
<.highlight_search_terms text={result.matching_search_term} />
@@ -2,7 +2,9 @@
<.link href={~p"/sources"}>
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">Edit Source</h2>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Editing "<%= @source.custom_name %>"
</h2>
</div>
<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">
@@ -2,7 +2,7 @@
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
<nav>
<.link href={~p"/sources/new"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
</.button>
</.link>
@@ -14,19 +14,18 @@
<div class="flex flex-col gap-10 min-w-max">
<.table rows={@sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<%= source.custom_name || source.collection_name %>
<.subtle_link href={~p"/sources/#{source.id}"}>
<%= source.custom_name || source.collection_name %>
</.subtle_link>
</:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={source} label="Media Profile">
<.link
href={~p"/media_profiles/#{source.media_profile_id}"}
class="hover:text-secondary duration-200 ease-in-out"
>
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
<%= source.media_profile.name %>
</.link>
</.subtle_link>
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
@@ -4,13 +4,13 @@
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
</.link>
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
Source <span class="hidden sm:inline">#<%= @source.id %></span>
<%= @source.custom_name %>
</h2>
</div>
<nav>
<.link href={~p"/sources/#{@source}/edit"}>
<.button color="bg-primary" rounding="rounded-full">
<.button color="bg-primary" rounding="rounded-lg">
<.icon name="hero-pencil-square" class="mr-2" /> Edit <span class="hidden sm:inline pl-1">Source</span>
</.button>
</.link>
@@ -38,7 +38,7 @@
method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
>
<.button color="bg-meta-1" rounding="rounded-full">
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source
</.button>
</.link>
@@ -48,7 +48,7 @@
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
class="mt-5 md:mt-0"
>
<.button color="bg-meta-1" rounding="rounded-full">
<.button color="bg-meta-1" rounding="rounded-lg">
Delete Source and Files
</.button>
</.link>
@@ -1,11 +1,22 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.simple_form
:let={f}
for={@changeset}
action={@action}
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<h3 class="mt-8 text-2xl text-black dark:text-white">
General Options
</h3>
<section class="flex justify-between items-center mt-8">
<h3 class=" text-2xl text-black dark:text-white">
General Options
</h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span>
</span>
</section>
<.input
field={f[:custom_name]}
@@ -41,7 +52,7 @@
<.input
field={f[:fast_index]}
type="toggle"
label="Use Fast Indexing?"
label="Use Fast Indexing"
label_suffix="(pro)"
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
/>
@@ -54,7 +65,7 @@
<.input
field={f[:download_media]}
type="toggle"
label="Download Media?"
label="Download Media"
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
/>
@@ -69,7 +80,24 @@
help="Only download media uploaded after this date. Leave blank to download all media. Must be in YYYY-MM-DD format"
/>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto">Save Source</.button>
<section x-show="advancedMode">
<h3 class="mt-8 text-2xl text-black dark:text-white">
Advanced Options
</h3>
<p class="text-sm mt-2">
Tread carefully
</p>
<.input
field={f[:title_filter_regex]}
type="text"
label="Title Filter Regex"
placeholder="(?i)^How to Bike$"
help="A PCRE-compatible regex. Only media with titles that match this regex will be downloaded. Look up 'SQLean Regex docs' for more"
/>
</section>
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Source</.button>
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
<.fast_indexing_help />
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do
[
app: :pinchflat,
version: "0.1.0-alpha.5",
version: "0.1.0-alpha.7",
elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.AddNfoPathToSources do
use Ecto.Migration
def change do
alter table(:sources) do
add :nfo_filepath, :string
add :series_directory, :string
end
end
end
@@ -0,0 +1,15 @@
defmodule Pinchflat.Repo.Migrations.AddSourcePhotosFields do
use Ecto.Migration
def change do
alter table(:sources) do
add :fanart_filepath, :string
add :poster_filepath, :string
add :banner_filepath, :string
end
alter table(:media_profiles) do
add :download_source_images, :boolean, default: false, null: false
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddTitleRegexToSource do
use Ecto.Migration
def change do
alter table(:sources) do
add :title_filter_regex, :string
end
end
end
@@ -0,0 +1,7 @@
defmodule Pinchflat.Repo.Migrations.RemoveUniqueIndexFromSources do
use Ecto.Migration
def change do
drop unique_index(:sources, [:collection_id, :media_profile_id])
end
end
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

@@ -222,6 +222,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
end
end
describe "build_output_path_for/1" do
test "builds an output path for a source", %{media_item: media_item} do
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
assert path == "/tmp/test/media/%(title)S.%(ext)s"
end
end
defp update_media_profile_attribute(media_item_with_preloads, attrs) do
media_item_with_preloads.source.media_profile
|> Profiles.change_media_profile(attrs)
@@ -107,4 +107,37 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
end
end
describe "cp_p!/2" do
test "copies a file from source to destination" do
source = "#{tmpfile_directory()}/source.json"
FilesystemHelpers.write_p!(source, "TEST")
destination = "#{tmpfile_directory()}/destination.json"
refute File.exists?(destination)
FilesystemHelpers.cp_p!(source, destination)
assert File.exists?(destination)
assert File.read!(destination) == "TEST"
File.rm!(source)
File.rm!(destination)
end
test "creates directories as needed" do
source = "#{tmpfile_directory()}/source.json"
FilesystemHelpers.write_p!(source, "TEST")
destination = "#{tmpfile_directory()}/foo/bar/destination.json"
refute File.exists?(destination)
FilesystemHelpers.cp_p!(source, destination)
assert File.exists?(destination)
File.rm!(source)
File.rm!(destination)
end
end
defp tmpfile_directory do
Application.get_env(:pinchflat, :tmpfile_directory)
end
end
+20
View File
@@ -239,6 +239,26 @@ defmodule Pinchflat.MediaTest do
end
end
describe "list_pending_media_items_for/1 when testing title regex" do
test "returns only media items that match the title regex" do
source = source_fixture(%{title_filter_regex: "(?i)^FOO$"})
matching_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
_non_matching_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
assert Media.list_pending_media_items_for(source) == [matching_media_item]
end
test "does not apply a regex if none is specified" do
source = source_fixture(%{title_filter_regex: nil})
media_item_one = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "foo"})
media_item_two = media_item_fixture(%{source_id: source.id, media_filepath: nil, title: "bar"})
assert Media.list_pending_media_items_for(source) == [media_item_one, media_item_two]
end
end
describe "list_downloaded_media_items_for/1" do
test "returns only media items with a media_filepath" do
source = source_fixture()
@@ -92,4 +92,52 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
assert Helpers.parse_upload_date(upload_date) == ~D[2021-01-01]
end
end
describe "series_directory_from_media_filepath/1" do
test "returns base series directory if filepaths are setup as expected" do
good_filepaths = [
"/media/season1/episode.mp4",
"/media/season 1/episode.mp4",
"/media/season.1/episode.mp4",
"/media/season_1/episode.mp4",
"/media/season-1/episode.mp4",
"/media/SEASON 1/episode.mp4",
"/media/SEASON.1/episode.mp4",
"/media/s1/episode.mp4",
"/media/s.1/episode.mp4",
"/media/s_1/episode.mp4",
"/media/s-1/episode.mp4",
"/media/s 1/episode.mp4",
"/media/S1/episode.mp4",
"/media/S.1/episode.mp4"
]
for filepath <- good_filepaths do
assert {:ok, "/media"} = Helpers.series_directory_from_media_filepath(filepath)
end
end
test "returns an error if the season filepath can't be determined" do
bad_filepaths = [
"/media/1/episode.mp4",
"/media/(s1)/episode.mp4",
"/media/episode.mp4",
"/media/s1e1/episode.mp4",
"/media/s1 e1/episode.mp4",
"/media/s1 (something else)/episode.mp4",
"/media/season1e1/episode.mp4",
"/media/season1 e1/episode.mp4",
"/media/seasoning1/episode.mp4",
"/media/season/episode.mp4",
"/media/series1/episode.mp4",
"/media/s/episode.mp4",
"/media/foo",
"/media/bar/"
]
for filepath <- bad_filepaths do
assert {:error, :indeterminable} = Helpers.series_directory_from_media_filepath(filepath)
end
end
end
end
+30 -18
View File
@@ -2,37 +2,49 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
use Pinchflat.DataCase
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Filesystem.FilesystemHelpers
setup do
{:ok, %{metadata: render_parsed_metadata(:media_metadata)}}
filepath = FilesystemHelpers.generate_metadata_tmpfile(:json)
on_exit(fn -> File.rm!(filepath) end)
{:ok,
%{
metadata: render_parsed_metadata(:media_metadata),
filepath: filepath
}}
end
describe "build_and_store_for_media_item/1" do
test "returns the filepath", %{metadata: metadata} do
result = NfoBuilder.build_and_store_for_media_item(metadata)
describe "build_and_store_for_media_item/2" do
test "returns the filepath", %{metadata: metadata, filepath: filepath} do
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
assert File.exists?(result)
File.rm!(result)
end
test "builds filepath based on media location", %{metadata: metadata} do
result = NfoBuilder.build_and_store_for_media_item(metadata)
assert String.contains?(result, Path.rootname(metadata["filepath"]))
assert String.ends_with?(result, ".nfo")
File.rm!(result)
end
test "builds an NFO file", %{metadata: metadata} do
result = NfoBuilder.build_and_store_for_media_item(metadata)
test "builds an NFO file", %{metadata: metadata, filepath: filepath} do
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end
end
File.rm!(result)
describe "build_and_store_for_source/2" do
test "returns the filepath", %{metadata: metadata, filepath: filepath} do
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
assert File.exists?(result)
end
test "builds an NFO file", %{metadata: metadata, filepath: filepath} do
result = NfoBuilder.build_and_store_for_source(filepath, metadata)
nfo = File.read!(result)
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end
end
end
@@ -0,0 +1,78 @@
defmodule Pinchflat.Metadata.SourceImageParserTest do
use Pinchflat.DataCase
alias Pinchflat.Metadata.SourceImageParser
@base_dir Application.compile_env(:pinchflat, :tmpfile_directory)
describe "store_source_images/2" do
test "returns a map of image types and locations" do
metadata = render_parsed_metadata(:channel_source_metadata)
expected = %{
banner_filepath: "#{@base_dir}/banner.jpg",
fanart_filepath: "#{@base_dir}/fanart.jpg",
poster_filepath: "#{@base_dir}/poster.jpg"
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
end
test "returns the avatar_uncropped as the poster" do
metadata = %{
"thumbnails" => [
%{"id" => "avatar_uncropped", "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 "returns the banner_uncropped as the fanart" do
metadata = %{
"thumbnails" => [
%{"id" => "banner_uncropped", "filepath" => "/app/test/support/files/channel_photos/a.0.jpg"}
]
}
expected = %{
fanart_filepath: "#{@base_dir}/fanart.jpg"
}
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
end
test "doesn't return a banner if no suitable images are found" do
metadata = %{
"thumbnails" => [
%{
"id" => "1",
"filepath" => "foo.jpg",
"width" => 100,
"height" => 100
}
]
}
expected = %{}
assert SourceImageParser.store_source_images(@base_dir, metadata) == expected
end
test "doesn't blow up if empty metadata is passed" do
metadata = %{}
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
test "doesn't blow up if no thumbnails are passed" do
metadata = %{"thumbnails" => []}
assert SourceImageParser.store_source_images(@base_dir, metadata) == %{}
end
end
end
@@ -2,10 +2,15 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
use Pinchflat.DataCase
import Mox
import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Metadata.SourceMetadataStorageWorker
@source_details_ot "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
@metadata_ot "playlist:%()j"
setup :verify_on_exit!
describe "kickoff_with_task/1" do
@@ -27,8 +32,31 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
end
describe "perform/1" do
test "won't call itself in an infinite loop" 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 -> {:ok, "{}"}
end)
source = source_fixture()
perform_job(SourceMetadataStorageWorker, %{id: source.id})
assert [] = all_enqueued(worker: SourceMetadataStorageWorker)
end
test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(SourceMetadataStorageWorker, %{id: 0})
end
end
describe "perform/1 when testing metadata storage" do
test "sets metadata location for source" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, "{}"}
end)
source = Repo.preload(source_fixture(), :metadata)
refute source.metadata
@@ -43,7 +71,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
test "fetches and stores returned metadata for source" do
source = source_fixture()
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, file_contents} end)
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot -> {:ok, source_details_return_fixture()}
_url, _opts, ot when ot == @metadata_ot -> {:ok, file_contents}
end)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.preload(Repo.reload(source), :metadata)
@@ -51,32 +83,180 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
assert metadata == %{"title" => "test"}
end
end
test "won't call itself in an infinite loop" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
source = source_fixture()
describe "perform/1 when testing source image downloading" do
test "downloads and stores source images" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
assert source.fanart_filepath
assert source.poster_filepath
assert source.banner_filepath
assert File.exists?(source.fanart_filepath)
assert File.exists?(source.poster_filepath)
assert File.exists?(source.banner_filepath)
Sources.delete_source(source, delete_files: true)
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 ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: false})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
assert [_] = all_enqueued(worker: SourceMetadataStorageWorker)
refute source.fanart_filepath
refute source.poster_filepath
refute source.banner_filepath
end
test "doesn't prevent over source jobs from running" do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
source_1 = source_fixture()
source_2 = source_fixture()
test "does not store source images if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
perform_job(SourceMetadataStorageWorker, %{id: source_1.id})
perform_job(SourceMetadataStorageWorker, %{id: source_1.id})
perform_job(SourceMetadataStorageWorker, %{id: source_2.id})
perform_job(SourceMetadataStorageWorker, %{id: source_2.id})
{:ok, source_details_return_fixture(%{filename: filename})}
assert [_, _] = all_enqueued(worker: SourceMetadataStorageWorker)
_url, _opts, ot when ot == @metadata_ot ->
{:ok, render_metadata(:channel_source_metadata)}
end)
profile = media_profile_fixture(%{download_source_images: true})
source = source_fixture(media_profile_id: profile.id)
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.fanart_filepath
refute source.poster_filepath
refute source.banner_filepath
end
end
describe "perform/1 when determining the series_directory" do
test "sets the series directory based on the returned media filepath" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, "{}"}
end)
source = source_fixture(%{series_directory: nil})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
assert source.series_directory
end
test "does not blow up if the record doesn't exist" do
assert :ok = perform_job(SourceMetadataStorageWorker, %{id: 0})
test "does not set the series directory if it cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, "{}"}
end)
source = source_fixture(%{series_directory: nil})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.series_directory
end
end
describe "perform/1 when storing the series NFO" do
test "stores the NFO if specified" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, "{}"}
end)
profile = media_profile_fixture(%{download_nfo: true})
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
assert source.nfo_filepath
assert source.nfo_filepath == Path.join([source.series_directory, "tvshow.nfo"])
assert File.exists?(source.nfo_filepath)
File.rm!(source.nfo_filepath)
end
test "does not store the NFO if not specified" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "Season 1", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, "{}"}
end)
profile = media_profile_fixture(%{download_nfo: false})
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.nfo_filepath
end
test "does not store the NFO if the series directory cannot be determined" do
stub(YtDlpRunnerMock, :run, fn
_url, _opts, ot when ot == @source_details_ot ->
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
{:ok, source_details_return_fixture(%{filename: filename})}
_url, _opts, ot when ot == @metadata_ot ->
{:ok, "{}"}
end)
profile = media_profile_fixture(%{download_nfo: true})
source = source_fixture(%{nfo_filepath: nil, media_profile_id: profile.id})
perform_job(SourceMetadataStorageWorker, %{id: source.id})
source = Repo.reload(source)
refute source.nfo_filepath
end
end
end
+53 -46
View File
@@ -8,6 +8,7 @@ defmodule Pinchflat.SourcesTest do
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.Filesystem.FilesystemHelpers
alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.FastIndexing.FastIndexingWorker
@@ -57,7 +58,7 @@ defmodule Pinchflat.SourcesTest do
end
end
describe "create_source/1" do
describe "create_source/2" do
test "creates a source and adds name + ID from runner response for channels" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
@@ -137,49 +138,6 @@ defmodule Pinchflat.SourcesTest do
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
end
test "creation enforces uniqueness of collection_id scoped to the media_profile" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_once_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Source{}} = Sources.create_source(valid_once_attrs)
assert {:error, %Ecto.Changeset{}} = Sources.create_source(valid_once_attrs)
end
test "creation lets you duplicate collection_ids as long as the media profile is different" do
expect(YtDlpRunnerMock, :run, 2, fn _url, _opts, _ot ->
{:ok,
Phoenix.json_library().encode!(%{
channel: "some channel name",
channel_id: "some_channel_id_12345678",
playlist_id: "some_channel_id_12345678",
playlist_title: "some channel name - videos"
})}
end)
valid_attrs = %{
name: "some name",
original_url: "https://www.youtube.com/channel/abc123"
}
source_1_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
source_2_attrs = Map.merge(valid_attrs, %{media_profile_id: media_profile_fixture().id})
assert {:ok, %Source{}} = Sources.create_source(source_1_attrs)
assert {:ok, %Source{}} = Sources.create_source(source_2_attrs)
end
test "creation will schedule the indexing task" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
@@ -253,7 +211,23 @@ defmodule Pinchflat.SourcesTest do
end
end
describe "update_source/2" do
describe "create_source/2 when testing options" do
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
expect(YtDlpRunnerMock, :run, &channel_mock/3)
valid_attrs = %{
media_profile_id: media_profile_fixture().id,
original_url: "https://www.youtube.com/channel/abc123"
}
assert {:ok, %Source{}} = Sources.create_source(valid_attrs, run_post_commit_tasks: false)
refute_enqueued(worker: MediaCollectionIndexingWorker)
refute_enqueued(worker: SourceMetadataStorageWorker)
end
end
describe "update_source/3" do
test "updates with valid data updates the source" do
source = source_fixture()
update_attrs = %{collection_name: "some updated name"}
@@ -426,6 +400,20 @@ defmodule Pinchflat.SourcesTest do
end
end
describe "update_source/3 when testing options" do
test "run_post_commit_tasks: false won't enqueue post-commit tasks" do
source = source_fixture(%{fast_index: false, download_media: false, index_frequency_minutes: -1})
update_attrs = %{fast_index: true, download_media: true, index_frequency_minutes: 100}
assert {:ok, %Source{}} = Sources.update_source(source, update_attrs, run_post_commit_tasks: false)
refute_enqueued(worker: MediaCollectionIndexingWorker)
refute_enqueued(worker: SourceMetadataStorageWorker)
refute_enqueued(worker: MediaDownloadWorker)
refute_enqueued(worker: FastIndexingWorker)
end
end
describe "delete_source/2" do
test "it deletes the source" do
source = source_fixture()
@@ -474,9 +462,19 @@ defmodule Pinchflat.SourcesTest do
{:ok, updated_source} = Sources.update_source(source, update_attrs)
assert {:ok, _} = Sources.delete_source(updated_source, delete_files: true)
assert {:ok, _} = Sources.delete_source(updated_source)
refute File.exists?(updated_source.metadata.metadata_filepath)
end
test "does not delete the source's non-metadata files" do
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
source = source_fixture(%{nfo_filepath: filepath})
assert {:ok, _} = Sources.delete_source(source)
assert File.exists?(filepath)
File.rm!(filepath)
end
end
describe "delete_source/2 when deleting files" do
@@ -498,6 +496,15 @@ defmodule Pinchflat.SourcesTest do
refute File.exists?(media_item.media_filepath)
end
test "deletes the source's non-metadata files" do
filepath = FilesystemHelpers.generate_metadata_tmpfile(:nfo)
source = source_fixture(%{nfo_filepath: filepath})
assert {:ok, _} = Sources.delete_source(source, delete_files: true)
refute File.exists?(filepath)
end
end
describe "change_source/3" do
@@ -97,7 +97,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 == [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
assert ot == "%(.{channel,channel_id,playlist_id,playlist_title})j"
assert ot == "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
{:ok, "{}"}
end)
@@ -151,5 +151,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url)
end
test "allows you to pass additional opts" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert opts == [playlist_items: 0, real_opt: :yup]
{:ok, "{}"}
end)
assert {:ok, _} = MediaCollection.get_source_metadata(@channel_url, real_opt: :yup)
end
end
end
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
media_profile: media_profile
} do
conn = get(conn, ~p"/media_profiles/#{media_profile}/edit")
assert html_response(conn, 200) =~ "Edit Media Profile"
assert html_response(conn, 200) =~ "Editing \"#{media_profile.name}\""
end
end
@@ -98,7 +98,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
test "renders errors when data is invalid", %{conn: conn, media_profile: media_profile} do
conn = put(conn, ~p"/media_profiles/#{media_profile}", media_profile: @invalid_attrs)
assert html_response(conn, 200) =~ "Edit Media Profile"
assert html_response(conn, 200) =~ "Editing \"#{media_profile.name}\""
end
end
@@ -91,7 +91,7 @@ defmodule PinchflatWeb.SourceControllerTest do
test "renders form for editing chosen source", %{conn: conn, source: source} do
conn = get(conn, ~p"/sources/#{source}/edit")
assert html_response(conn, 200) =~ "Edit Source"
assert html_response(conn, 200) =~ "Editing \"#{source.custom_name}\""
end
end
@@ -114,7 +114,7 @@ defmodule PinchflatWeb.SourceControllerTest do
invalid_attrs: invalid_attrs
} do
conn = put(conn, ~p"/sources/#{source}", source: invalid_attrs)
assert html_response(conn, 200) =~ "Edit Source"
assert html_response(conn, 200) =~ "Editing \"#{source.custom_name}\""
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Some files were not shown because too many files have changed in this diff Show More