Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6d8e15670 | |||
| 2906ff1b30 | |||
| 0582a7bfd5 | |||
| 3db2e8190f | |||
| 087c9c2d0f | |||
| c046abf36f | |||
| 70dd95211f | |||
| 8491f8b317 |
@@ -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.
|
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?
|
## What is Pinchflat?
|
||||||
|
|
||||||
TODO: expand on this.
|
TODO: expand on this.
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ config :pinchflat,
|
|||||||
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
|
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
|
||||||
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
|
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
|
if config_env() == :prod do
|
||||||
config_path =
|
config_path =
|
||||||
System.get_env("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.
|
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.Media.MediaItem
|
||||||
alias Pinchflat.Downloading.OutputPathBuilder
|
alias Pinchflat.Downloading.OutputPathBuilder
|
||||||
|
|
||||||
@@ -26,6 +27,18 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
{:ok, built_options}
|
{:ok, built_options}
|
||||||
end
|
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
|
defp default_options do
|
||||||
[:no_progress, :windows_filenames]
|
[:no_progress, :windows_filenames]
|
||||||
end
|
end
|
||||||
@@ -104,23 +117,19 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp output_options(media_item_with_preloads) do
|
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
|
end
|
||||||
|
|
||||||
defp build_output_path(string, media_item_with_preloads) do
|
defp build_output_path(string, source) do
|
||||||
additional_options_map = output_options_map(media_item_with_preloads)
|
additional_options_map = output_options_map(source)
|
||||||
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
|
{:ok, output_path} = OutputPathBuilder.build(string, additional_options_map)
|
||||||
|
|
||||||
Path.join(base_directory(), output_path)
|
Path.join(base_directory(), output_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp output_options_map(media_item_with_preloads) do
|
defp output_options_map(source) do
|
||||||
source = media_item_with_preloads.source
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"source_custom_name" => source.custom_name,
|
"source_custom_name" => source.custom_name,
|
||||||
"source_collection_type" => source.collection_type
|
"source_collection_type" => source.collection_type
|
||||||
@@ -137,7 +146,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
|||||||
|> String.split(~r{\.}, include_captures: true)
|
|> String.split(~r{\.}, include_captures: true)
|
||||||
|> List.insert_at(-3, "-thumb")
|
|> List.insert_at(-3, "-thumb")
|
||||||
|> Enum.join()
|
|> Enum.join()
|
||||||
|> build_output_path(media_item_with_preloads)
|
|> build_output_path(media_item_with_preloads.source)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp base_directory do
|
defp base_directory do
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
|||||||
|
|
||||||
defp determine_nfo_filepath(media_item, parsed_json) do
|
defp determine_nfo_filepath(media_item, parsed_json) do
|
||||||
if media_item.source.media_profile.download_nfo 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
|
else
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ defmodule Pinchflat.Filesystem.FilesystemHelpers do
|
|||||||
File.write!(filepath, content, modes)
|
File.write!(filepath, content, modes)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Fetches the file size of a media item and saves it to the database.
|
Fetches the file size of a media item and saves it to the database.
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ defmodule Pinchflat.Media do
|
|||||||
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
||||||
|> where(^build_format_clauses(media_profile))
|
|> where(^build_format_clauses(media_profile))
|
||||||
|> where(^maybe_apply_cutoff_date(source))
|
|> where(^maybe_apply_cutoff_date(source))
|
||||||
|
|> where(^maybe_apply_title_regex(source))
|
||||||
|> Repo.maybe_limit(limit)
|
|> Repo.maybe_limit(limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
@@ -247,6 +248,14 @@ defmodule Pinchflat.Media do
|
|||||||
end
|
end
|
||||||
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
|
defp build_format_clauses(media_profile) do
|
||||||
mapped_struct = Map.from_struct(media_profile)
|
mapped_struct = Map.from_struct(media_profile)
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,43 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
|||||||
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
Date.from_iso8601!("#{year}-#{month}-#{day}")
|
||||||
end
|
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
|
defp fetch_thumbnail_from_url(url) do
|
||||||
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
|
||||||
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
{:ok, body} = http_client.get(url, [], body_format: :binary)
|
||||||
|
|||||||
@@ -9,13 +9,11 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Builds an NFO file for a media item (read: single "episode") and
|
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
|
stores it at the specified location.
|
||||||
as the media file, but with a .nfo extension.
|
|
||||||
|
|
||||||
Returns the filepath of the NFO file.
|
Returns the filepath of the NFO file.
|
||||||
"""
|
"""
|
||||||
def build_and_store_for_media_item(metadata) do
|
def build_and_store_for_media_item(filepath, metadata) do
|
||||||
filepath = Path.rootname(metadata["filepath"]) <> ".nfo"
|
|
||||||
nfo = build_for_media_item(metadata)
|
nfo = build_for_media_item(metadata)
|
||||||
|
|
||||||
FilesystemHelpers.write_p!(filepath, nfo)
|
FilesystemHelpers.write_p!(filepath, nfo)
|
||||||
@@ -23,6 +21,20 @@ defmodule Pinchflat.Metadata.NfoBuilder do
|
|||||||
filepath
|
filepath
|
||||||
end
|
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
|
defp build_for_media_item(metadata) do
|
||||||
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
|
upload_date = MetadataFileHelpers.parse_upload_date(metadata["upload_date"])
|
||||||
# Cribbed from a combination of the Kodi wiki, ytdl-nfo, and ytdl-sub.
|
# 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>
|
<showtitle>#{metadata["uploader"]}</showtitle>
|
||||||
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
|
||||||
<plot>#{metadata["description"]}</plot>
|
<plot>#{metadata["description"]}</plot>
|
||||||
<premiered>#{upload_date}</premiered>
|
<aired>#{upload_date}</aired>
|
||||||
<season>#{upload_date.year}</season>
|
<season>#{upload_date.year}</season>
|
||||||
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
|
||||||
<genre>YouTube</genre>
|
<genre>YouTube</genre>
|
||||||
</episodedetails>
|
</episodedetails>
|
||||||
"""
|
"""
|
||||||
end
|
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
|
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,
|
use Oban.Worker,
|
||||||
queue: :remote_metadata,
|
queue: :remote_metadata,
|
||||||
tags: ["media_source", "source_metadata", "remote_metadata"],
|
tags: ["media_source", "source_metadata", "remote_metadata"],
|
||||||
max_attempts: 1,
|
max_attempts: 3
|
||||||
# This is the only thing stopping this job from calling itself
|
|
||||||
# in an infinite loop.
|
|
||||||
unique: [period: 600]
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@@ -15,8 +12,12 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
alias Pinchflat.Repo
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
|
alias Pinchflat.Utils.StringUtils
|
||||||
|
alias Pinchflat.Metadata.NfoBuilder
|
||||||
alias Pinchflat.YtDlp.MediaCollection
|
alias Pinchflat.YtDlp.MediaCollection
|
||||||
|
alias Pinchflat.Metadata.SourceImageParser
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
|
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the source metadata storage worker and creates a task for the source.
|
Starts the source metadata storage worker and creates a task for the source.
|
||||||
@@ -30,27 +31,81 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
Returns :ok
|
||||||
"""
|
"""
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
def perform(%Oban.Job{args: %{"id" => source_id}}) do
|
||||||
source = Repo.preload(Sources.get_source!(source_id), :metadata)
|
source = Repo.preload(Sources.get_source!(source_id), [:metadata, :media_profile])
|
||||||
{:ok, metadata} = MediaCollection.get_source_metadata(source.original_url)
|
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)
|
# `run_post_commit_tasks: false` prevents this from running in an infinite loop
|
||||||
# to once, per source, per x minutes. This is to prevent a job from calling itself
|
Sources.update_source(
|
||||||
# in an infinite loop.
|
source,
|
||||||
Sources.update_source(source, %{
|
Map.merge(
|
||||||
metadata: %{
|
%{
|
||||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(source, metadata)
|
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
|
:ok
|
||||||
rescue
|
rescue
|
||||||
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
Ecto.NoResultsError -> Logger.info("#{__MODULE__} discarded: source #{source_id} not found")
|
||||||
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
Ecto.StaleEntryError -> Logger.info("#{__MODULE__} discarded: source #{source_id} stale")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
sub_langs
|
sub_langs
|
||||||
download_thumbnail
|
download_thumbnail
|
||||||
embed_thumbnail
|
embed_thumbnail
|
||||||
|
download_source_images
|
||||||
download_metadata
|
download_metadata
|
||||||
embed_metadata
|
embed_metadata
|
||||||
download_nfo
|
download_nfo
|
||||||
@@ -40,6 +41,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
|||||||
|
|
||||||
field :download_thumbnail, :boolean, default: false
|
field :download_thumbnail, :boolean, default: false
|
||||||
field :embed_thumbnail, :boolean, default: false
|
field :embed_thumbnail, :boolean, default: false
|
||||||
|
field :download_source_images, :boolean, default: false
|
||||||
|
|
||||||
field :download_metadata, :boolean, default: false
|
field :download_metadata, :boolean, default: false
|
||||||
field :embed_metadata, :boolean, default: false
|
field :embed_metadata, :boolean, default: false
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ defmodule Pinchflat.SlowIndexing.FileFollowerServer do
|
|||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
|
@poll_interval_ms Application.compile_env(:pinchflat, :file_watcher_poll_interval)
|
||||||
@activity_timeout_ms 60_000
|
@activity_timeout_ms 600_000
|
||||||
|
|
||||||
# Client API
|
# Client API
|
||||||
@doc """
|
@doc """
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Pinchflat.Repo
|
||||||
alias Pinchflat.Media
|
alias Pinchflat.Media
|
||||||
alias Pinchflat.Tasks
|
alias Pinchflat.Tasks
|
||||||
alias Pinchflat.Sources
|
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
|
# See the method definition below for more info on how file watchers work
|
||||||
# (important reading if you're not familiar with it)
|
# (important reading if you're not familiar with it)
|
||||||
{:ok, media_attributes} = get_media_attributes_for_collection_and_setup_file_watcher(source)
|
{: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 =
|
result =
|
||||||
Enum.map(media_attributes, fn media_attrs ->
|
Enum.map(media_attributes, fn media_attrs ->
|
||||||
@@ -117,6 +121,10 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpers do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp create_media_item_and_enqueue_download(source, media_attrs) do
|
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
|
case Media.create_media_item_from_backend_attrs(source, media_attrs) do
|
||||||
{:ok, %MediaItem{} = media_item} ->
|
{:ok, %MediaItem{} = media_item} ->
|
||||||
if source.download_media && Media.pending_download?(media_item) do
|
if source.download_media && Media.pending_download?(media_item) do
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
collection_id
|
collection_id
|
||||||
collection_type
|
collection_type
|
||||||
custom_name
|
custom_name
|
||||||
|
nfo_filepath
|
||||||
|
poster_filepath
|
||||||
|
fanart_filepath
|
||||||
|
banner_filepath
|
||||||
|
series_directory
|
||||||
index_frequency_minutes
|
index_frequency_minutes
|
||||||
fast_index
|
fast_index
|
||||||
download_media
|
download_media
|
||||||
last_indexed_at
|
last_indexed_at
|
||||||
original_url
|
original_url
|
||||||
download_cutoff_date
|
download_cutoff_date
|
||||||
|
title_filter_regex
|
||||||
media_profile_id
|
media_profile_id
|
||||||
)a
|
)a
|
||||||
|
|
||||||
@@ -59,6 +65,13 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
# Only download media items that were published after this date
|
# Only download media items that were published after this date
|
||||||
field :download_cutoff_date, :date
|
field :download_cutoff_date, :date
|
||||||
field :original_url, :string
|
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
|
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)
|
|> dynamic_default(:custom_name, fn cs -> get_field(cs, :collection_name) end)
|
||||||
|> validate_required(required_fields)
|
|> validate_required(required_fields)
|
||||||
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
|> cast_assoc(:metadata, with: &SourceMetadata.changeset/2, required: false)
|
||||||
|> unique_constraint([:collection_id, :media_profile_id])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@@ -99,4 +111,9 @@ defmodule Pinchflat.Sources.Source do
|
|||||||
# minutes
|
# minutes
|
||||||
15
|
15
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def filepath_attributes do
|
||||||
|
~w(nfo_filepath fanart_filepath poster_filepath banner_filepath)a
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,15 +51,19 @@ defmodule Pinchflat.Sources do
|
|||||||
though we know it's going to fail so it picks up any addl. database errors
|
though we know it's going to fail so it picks up any addl. database errors
|
||||||
and fulfills our return contract.
|
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{}}
|
Returns {:ok, %Source{}} | {:error, %Ecto.Changeset{}}
|
||||||
"""
|
"""
|
||||||
def create_source(attrs) do
|
def create_source(attrs, opts \\ []) do
|
||||||
case change_source(%Source{}, attrs, :initial) do
|
case change_source(%Source{}, attrs, :initial) do
|
||||||
%Ecto.Changeset{valid?: true} ->
|
%Ecto.Changeset{valid?: true} ->
|
||||||
%Source{}
|
%Source{}
|
||||||
|> maybe_change_source_from_url(attrs)
|
|> maybe_change_source_from_url(attrs)
|
||||||
|> maybe_change_indexing_frequency()
|
|> maybe_change_indexing_frequency()
|
||||||
|> commit_and_handle_tasks()
|
|> commit_and_handle_tasks(opts)
|
||||||
|
|
||||||
changeset ->
|
changeset ->
|
||||||
Repo.insert(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
|
though we know it's going to fail so it picks up any addl. database errors
|
||||||
and fulfills our return contract.
|
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{}}
|
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
|
case change_source(source, attrs, :initial) do
|
||||||
%Ecto.Changeset{valid?: true} ->
|
%Ecto.Changeset{valid?: true} ->
|
||||||
source
|
source
|
||||||
|> maybe_change_source_from_url(attrs)
|
|> maybe_change_source_from_url(attrs)
|
||||||
|> maybe_change_indexing_frequency()
|
|> maybe_change_indexing_frequency()
|
||||||
|> commit_and_handle_tasks()
|
|> commit_and_handle_tasks(opts)
|
||||||
|
|
||||||
changeset ->
|
changeset ->
|
||||||
Repo.update(changeset)
|
Repo.update(changeset)
|
||||||
@@ -102,7 +110,6 @@ defmodule Pinchflat.Sources do
|
|||||||
"""
|
"""
|
||||||
def delete_source(%Source{} = source, opts \\ []) do
|
def delete_source(%Source{} = source, opts \\ []) do
|
||||||
delete_files = Keyword.get(opts, :delete_files, false)
|
delete_files = Keyword.get(opts, :delete_files, false)
|
||||||
|
|
||||||
Tasks.delete_tasks_for(source)
|
Tasks.delete_tasks_for(source)
|
||||||
|
|
||||||
source
|
source
|
||||||
@@ -111,7 +118,11 @@ defmodule Pinchflat.Sources do
|
|||||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
delete_source_metadata_files(source)
|
if delete_files do
|
||||||
|
delete_source_files(source)
|
||||||
|
end
|
||||||
|
|
||||||
|
delete_internal_metadata_files(source)
|
||||||
Repo.delete(source)
|
Repo.delete(source)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -134,22 +145,27 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
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{}
|
metadata = Repo.preload(source, :metadata).metadata || %SourceMetadata{}
|
||||||
mapped_struct = Map.from_struct(metadata)
|
mapped_struct = Map.from_struct(metadata)
|
||||||
|
|
||||||
filepaths =
|
SourceMetadata.filepath_attributes()
|
||||||
SourceMetadata.filepath_attributes()
|
|> Enum.map(fn field -> mapped_struct[field] end)
|
||||||
|> Enum.map(fn field -> mapped_struct[field] end)
|
|> Enum.filter(&is_binary/1)
|
||||||
|> Enum.filter(&is_binary/1)
|
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||||
|
|
||||||
Enum.each(filepaths, &FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp add_source_details_to_changeset(source, changeset) do
|
defp add_source_details_to_changeset(source, changeset) do
|
||||||
%Ecto.Changeset{changes: changes} = changeset
|
case MediaCollection.get_source_details(changeset.changes.original_url) do
|
||||||
|
|
||||||
case MediaCollection.get_source_details(changes.original_url) do
|
|
||||||
{:ok, source_details} ->
|
{:ok, source_details} ->
|
||||||
add_source_details_by_collection_type(source, changeset, source_details)
|
add_source_details_by_collection_type(source, changeset, source_details)
|
||||||
|
|
||||||
@@ -198,12 +214,16 @@ defmodule Pinchflat.Sources do
|
|||||||
end
|
end
|
||||||
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
|
case Repo.insert_or_update(changeset) do
|
||||||
{:ok, %Source{} = source} ->
|
{:ok, %Source{} = source} ->
|
||||||
maybe_handle_media_tasks(changeset, source)
|
if run_post_commit_tasks do
|
||||||
maybe_run_indexing_task(changeset, source)
|
maybe_handle_media_tasks(changeset, source)
|
||||||
run_metadata_storage_task(source)
|
maybe_run_indexing_task(changeset, source)
|
||||||
|
run_metadata_storage_task(source)
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, source}
|
{:ok, source}
|
||||||
|
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
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
|
# `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
|
# 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
|
# available formats since we're just getting the source details
|
||||||
opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
|
command_opts = [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1] ++ addl_opts
|
||||||
output_template = "%(.{channel,channel_id,playlist_id,playlist_title})j"
|
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, parsed_json} <- Phoenix.json_library().decode(output) do
|
||||||
{:ok, format_source_details(parsed_json)}
|
{:ok, format_source_details(parsed_json)}
|
||||||
else
|
else
|
||||||
@@ -94,8 +94,8 @@ defmodule Pinchflat.YtDlp.MediaCollection do
|
|||||||
|
|
||||||
Returns {:ok, map()} | {:error, any, ...}.
|
Returns {:ok, map()} | {:error, any, ...}.
|
||||||
"""
|
"""
|
||||||
def get_source_metadata(source_url) do
|
def get_source_metadata(source_url, addl_opts \\ []) do
|
||||||
opts = [playlist_items: 0]
|
opts = [playlist_items: 0] ++ addl_opts
|
||||||
output_template = "playlist:%()j"
|
output_template = "playlist:%()j"
|
||||||
|
|
||||||
with {:ok, output} <- backend_runner().run(source_url, opts, output_template),
|
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_id: response["channel_id"],
|
||||||
channel_name: response["channel"],
|
channel_name: response["channel"],
|
||||||
playlist_id: response["playlist_id"],
|
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
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
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 """
|
@doc """
|
||||||
Renders an icon as a link with the given href.
|
Renders an icon as a link with the given href.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<div class="relative flex flex-1 flex-col overflow-y-auto overflow-x-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">
|
<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="w-65 px-4 py-2 shadow-2 md:px-6">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center gap-2 py-2">
|
||||||
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
|
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||||
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 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
|
<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"
|
@click.stop="sidebarVisible = !sidebarVisible"
|
||||||
>
|
>
|
||||||
<.icon name="hero-bars-3" />
|
<.icon name="hero-bars-3" />
|
||||||
</button>
|
</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>
|
||||||
<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">
|
<div class="relative">
|
||||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
|
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
|
||||||
<.icon name="hero-magnifying-glass" />
|
<.icon name="hero-magnifying-glass" />
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
name="q"
|
name="q"
|
||||||
value={@params["q"]}
|
value={@params["q"]}
|
||||||
placeholder="Type to search..."
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
<aside
|
<aside
|
||||||
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
|
x-bind:class="sidebarVisible ? 'translate-x-0' : '-translate-x-full'"
|
||||||
class={[
|
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"
|
"bg-black duration-300 ease-linear shadow-lg sm:shadow-none dark:bg-boxdark lg:static lg:translate-x-0"
|
||||||
]}
|
]}
|
||||||
@click.outside="sidebarVisible = false"
|
@click.outside="sidebarVisible = false"
|
||||||
>
|
>
|
||||||
<section>
|
<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">
|
<a href="/" class="flex items-center">
|
||||||
<img src={~p"/images/logo.png?cachebust=2024-02-29"} alt="Pinchflat" class="w-9 h-9" />
|
<img src={~p"/images/logo.png?cachebust=2024-03-20"} alt="Pinchflat" class="w-auto" />
|
||||||
<h2 class="text-xl font-bold text-white pl-2">Pinchflat</h2>
|
|
||||||
</a>
|
</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" />
|
<.icon name="hero-arrow-left" class="fill-current" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
|
<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>
|
<h3 class="mb-4 ml-4 text-sm font-medium text-bodydark2">MENU</h3>
|
||||||
<div class="flex flex-col justify-between">
|
<div class="flex flex-col justify-between">
|
||||||
<ul class="mb-6 flex flex-col gap-1.5">
|
<ul class="mb-6 flex flex-col gap-1.5">
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<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">
|
<ul class="mb-6 flex flex-col gap-1.5">
|
||||||
<.sidebar_item
|
<.sidebar_item
|
||||||
icon="hero-code-bracket"
|
icon="hero-code-bracket"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<%= assigns[:page_title] || "Pinchflat" %>
|
<%= assigns[:page_title] || "Pinchflat" %>
|
||||||
</.live_title>
|
</.live_title>
|
||||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
<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 defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
method="delete"
|
method="delete"
|
||||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
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
|
Delete Files
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<.link href={~p"/media_profiles"}>
|
<.link href={~p"/media_profiles"}>
|
||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.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>
|
||||||
|
|
||||||
<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="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>
|
</h2>
|
||||||
<nav>
|
<nav>
|
||||||
<.link href={~p"/media_profiles/new"}>
|
<.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>
|
<span class="font-bold text-xl mx-2">+</span> New <span class="hidden sm:inline pl-1">Media Profile</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -16,10 +16,9 @@
|
|||||||
<div class="flex flex-col gap-10 min-w-max">
|
<div class="flex flex-col gap-10 min-w-max">
|
||||||
<.table rows={@media_profiles} table_class="text-black dark:text-white">
|
<.table rows={@media_profiles} table_class="text-black dark:text-white">
|
||||||
<:col :let={media_profile} label="Name">
|
<:col :let={media_profile} label="Name">
|
||||||
<%= media_profile.name %>
|
<.subtle_link href={~p"/media_profiles/#{media_profile.id}"}>
|
||||||
</:col>
|
<%= media_profile.name %>
|
||||||
<:col :let={media_profile} label="Output Template">
|
</.subtle_link>
|
||||||
<code class="text-sm"><%= media_profile.output_path_template %></code>
|
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={media_profile} label="Preferred Resolution">
|
<:col :let={media_profile} label="Preferred Resolution">
|
||||||
<%= media_profile.preferred_resolution %>
|
<%= media_profile.preferred_resolution %>
|
||||||
|
|||||||
@@ -162,16 +162,6 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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">
|
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
||||||
Release Format Options
|
Release Format Options
|
||||||
</h3>
|
</h3>
|
||||||
@@ -181,7 +171,7 @@
|
|||||||
field={f[:shorts_behaviour]}
|
field={f[:shorts_behaviour]}
|
||||||
options={friendly_format_type_options()}
|
options={friendly_format_type_options()}
|
||||||
type="select"
|
type="select"
|
||||||
label="Include Shorts?"
|
label="Include Shorts"
|
||||||
help="Experimental. Please report any issues on GitHub"
|
help="Experimental. Please report any issues on GitHub"
|
||||||
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||||
/>
|
/>
|
||||||
@@ -192,7 +182,7 @@
|
|||||||
field={f[:livestream_behaviour]}
|
field={f[:livestream_behaviour]}
|
||||||
options={friendly_format_type_options()}
|
options={friendly_format_type_options()}
|
||||||
type="select"
|
type="select"
|
||||||
label="Include Livestreams?"
|
label="Include Livestreams"
|
||||||
help="Excludes media that comes from a past livestream"
|
help="Excludes media that comes from a past livestream"
|
||||||
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
|
||||||
/>
|
/>
|
||||||
@@ -213,7 +203,42 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</section>
|
||||||
|
|
||||||
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
|
<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" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-2">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<.link href={~p"/media_profiles/#{@media_profile}/edit"}>
|
<.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>
|
<.icon name="hero-pencil-square" class="mr-2" />Edit <span class="hidden sm:inline pl-1">Media Profile</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
method="delete"
|
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."
|
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
|
Delete Profile and its Sources
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.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."
|
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"
|
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
|
Delete Profile, Sources, and Files
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
|
<p class="text-md text-bodydark">Don't worry, you can create more Media Profiles later!</p>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<.link href={~p"/media_profiles/new"}>
|
<.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
|
<span class="font-bold mx-2">+</span> New Media Profile
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<.link href={~p"/sources/new"}>
|
<.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
|
<span class="font-bold mx-2">+</span> New Source
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
|
<p class="text-md text-bodydark">Feel free to add more Media Profiles or Sources in the meantime!</p>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<.link href={~p"/?onboarding=0"}>
|
<.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>
|
Let's Go <span class="font-bold mx-2">🚀</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="mb-6 flex gap-3 flex-row items-center justify-between">
|
<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">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<%= if match?([_|_], @search_results) do %>
|
<%= if match?([_|_], @search_results) do %>
|
||||||
<.table rows={@search_results} table_class="text-black dark:text-white">
|
<.table rows={@search_results} table_class="text-black dark:text-white">
|
||||||
<:col :let={result} label="Title">
|
<:col :let={result} label="Title">
|
||||||
<%= StringUtils.truncate(result.title, 40) %>
|
<%= result.title %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={result} label="Excerpt">
|
<:col :let={result} label="Excerpt">
|
||||||
<.highlight_search_terms text={result.matching_search_term} />
|
<.highlight_search_terms text={result.matching_search_term} />
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
<.link href={~p"/sources"}>
|
<.link href={~p"/sources"}>
|
||||||
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.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>
|
||||||
|
|
||||||
<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="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>
|
<h2 class="text-title-md2 font-bold text-black dark:text-white">Sources</h2>
|
||||||
<nav>
|
<nav>
|
||||||
<.link href={~p"/sources/new"}>
|
<.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>
|
<span class="font-bold mx-2">+</span> New <span class="hidden sm:inline pl-1">Source</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -14,19 +14,18 @@
|
|||||||
<div class="flex flex-col gap-10 min-w-max">
|
<div class="flex flex-col gap-10 min-w-max">
|
||||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||||
<:col :let={source} label="Name">
|
<: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>
|
||||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||||
<:col :let={source} label="Should Download?">
|
<:col :let={source} label="Should Download?">
|
||||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="Media Profile">
|
<:col :let={source} label="Media Profile">
|
||||||
<.link
|
<.subtle_link href={~p"/media_profiles/#{source.media_profile_id}"}>
|
||||||
href={~p"/media_profiles/#{source.media_profile_id}"}
|
|
||||||
class="hover:text-secondary duration-200 ease-in-out"
|
|
||||||
>
|
|
||||||
<%= source.media_profile.name %>
|
<%= source.media_profile.name %>
|
||||||
</.link>
|
</.subtle_link>
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={source} label="" class="flex place-content-evenly">
|
<:col :let={source} label="" class="flex place-content-evenly">
|
||||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
<.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" />
|
<.icon name="hero-arrow-left" class="w-10 h-10 hover:dark:text-white" />
|
||||||
</.link>
|
</.link>
|
||||||
<h2 class="text-title-md2 font-bold text-black dark:text-white ml-4">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<.link href={~p"/sources/#{@source}/edit"}>
|
<.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>
|
<.icon name="hero-pencil-square" class="mr-2" /> Edit <span class="hidden sm:inline pl-1">Source</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.link>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
method="delete"
|
method="delete"
|
||||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
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
|
Delete Source
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.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."
|
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"
|
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
|
Delete Source and Files
|
||||||
</.button>
|
</.button>
|
||||||
</.link>
|
</.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}>
|
<.error :if={@changeset.action}>
|
||||||
Oops, something went wrong! Please check the errors below.
|
Oops, something went wrong! Please check the errors below.
|
||||||
</.error>
|
</.error>
|
||||||
|
|
||||||
<h3 class="mt-8 text-2xl text-black dark:text-white">
|
<section class="flex justify-between items-center mt-8">
|
||||||
General Options
|
<h3 class=" text-2xl text-black dark:text-white">
|
||||||
</h3>
|
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
|
<.input
|
||||||
field={f[:custom_name]}
|
field={f[:custom_name]}
|
||||||
@@ -41,7 +52,7 @@
|
|||||||
<.input
|
<.input
|
||||||
field={f[:fast_index]}
|
field={f[:fast_index]}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
label="Use Fast Indexing?"
|
label="Use Fast Indexing"
|
||||||
label_suffix="(pro)"
|
label_suffix="(pro)"
|
||||||
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
|
help="Experimental. Ignores 'Index Frequency'. Recommended for large channels that upload frequently. See below for more info"
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +65,7 @@
|
|||||||
<.input
|
<.input
|
||||||
field={f[:download_media]}
|
field={f[:download_media]}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
label="Download Media?"
|
label="Download Media"
|
||||||
help="Unchecking still indexes media but it won't be downloaded until you enable this option"
|
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"
|
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">
|
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mb-5">
|
||||||
<.fast_indexing_help />
|
<.fast_indexing_help />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
|||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
app: :pinchflat,
|
app: :pinchflat,
|
||||||
version: "0.1.0-alpha.5",
|
version: "0.1.0-alpha.7",
|
||||||
elixir: "~> 1.16",
|
elixir: "~> 1.16",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
|
|||||||
@@ -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
|
||||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -222,6 +222,14 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
|||||||
end
|
end
|
||||||
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
|
defp update_media_profile_attribute(media_item_with_preloads, attrs) do
|
||||||
media_item_with_preloads.source.media_profile
|
media_item_with_preloads.source.media_profile
|
||||||
|> Profiles.change_media_profile(attrs)
|
|> Profiles.change_media_profile(attrs)
|
||||||
|
|||||||
@@ -107,4 +107,37 @@ defmodule Pinchflat.Filesystem.FilesystemHelpersTest do
|
|||||||
assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
assert {:error, _} = FilesystemHelpers.delete_file_and_remove_empty_directories(filepath)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -239,6 +239,26 @@ defmodule Pinchflat.MediaTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "list_downloaded_media_items_for/1" do
|
||||||
test "returns only media items with a media_filepath" do
|
test "returns only media items with a media_filepath" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
|
|||||||
@@ -92,4 +92,52 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
|
|||||||
assert Helpers.parse_upload_date(upload_date) == ~D[2021-01-01]
|
assert Helpers.parse_upload_date(upload_date) == ~D[2021-01-01]
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -2,37 +2,49 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
|
|||||||
use Pinchflat.DataCase
|
use Pinchflat.DataCase
|
||||||
|
|
||||||
alias Pinchflat.Metadata.NfoBuilder
|
alias Pinchflat.Metadata.NfoBuilder
|
||||||
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
|
|
||||||
setup do
|
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
|
end
|
||||||
|
|
||||||
describe "build_and_store_for_media_item/1" do
|
describe "build_and_store_for_media_item/2" do
|
||||||
test "returns the filepath", %{metadata: metadata} do
|
test "returns the filepath", %{metadata: metadata, filepath: filepath} do
|
||||||
result = NfoBuilder.build_and_store_for_media_item(metadata)
|
result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
|
||||||
|
|
||||||
assert File.exists?(result)
|
assert File.exists?(result)
|
||||||
|
|
||||||
File.rm!(result)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "builds filepath based on media location", %{metadata: metadata} do
|
test "builds an NFO file", %{metadata: metadata, filepath: filepath} do
|
||||||
result = NfoBuilder.build_and_store_for_media_item(metadata)
|
result = NfoBuilder.build_and_store_for_media_item(filepath, 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)
|
|
||||||
nfo = File.read!(result)
|
nfo = File.read!(result)
|
||||||
|
|
||||||
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
|
||||||
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
|
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
|
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
|
use Pinchflat.DataCase
|
||||||
import Mox
|
import Mox
|
||||||
import Pinchflat.SourcesFixtures
|
import Pinchflat.SourcesFixtures
|
||||||
|
import Pinchflat.ProfilesFixtures
|
||||||
|
|
||||||
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||||
|
|
||||||
|
@source_details_ot "%(.{channel,channel_id,playlist_id,playlist_title,filename})j"
|
||||||
|
@metadata_ot "playlist:%()j"
|
||||||
|
|
||||||
setup :verify_on_exit!
|
setup :verify_on_exit!
|
||||||
|
|
||||||
describe "kickoff_with_task/1" do
|
describe "kickoff_with_task/1" do
|
||||||
@@ -27,8 +32,31 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "perform/1" do
|
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
|
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)
|
source = Repo.preload(source_fixture(), :metadata)
|
||||||
|
|
||||||
refute source.metadata
|
refute source.metadata
|
||||||
@@ -43,7 +71,11 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
test "fetches and stores returned metadata for source" do
|
test "fetches and stores returned metadata for source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
file_contents = Phoenix.json_library().encode!(%{"title" => "test"})
|
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})
|
perform_job(SourceMetadataStorageWorker, %{id: source.id})
|
||||||
source = Repo.preload(Repo.reload(source), :metadata)
|
source = Repo.preload(Repo.reload(source), :metadata)
|
||||||
@@ -51,32 +83,180 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorkerTest do
|
|||||||
|
|
||||||
assert metadata == %{"title" => "test"}
|
assert metadata == %{"title" => "test"}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "won't call itself in an infinite loop" do
|
describe "perform/1 when testing source image downloading" do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
|
test "downloads and stores source images" do
|
||||||
source = source_fixture()
|
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})
|
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})
|
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
|
end
|
||||||
|
|
||||||
test "doesn't prevent over source jobs from running" do
|
test "does not store source images if the series directory cannot be determined" do
|
||||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, "{}"} end)
|
stub(YtDlpRunnerMock, :run, fn
|
||||||
source_1 = source_fixture()
|
_url, _opts, ot when ot == @source_details_ot ->
|
||||||
source_2 = source_fixture()
|
filename = Path.join([Application.get_env(:pinchflat, :media_directory), "foo", "bar.mp4"])
|
||||||
|
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source_1.id})
|
{:ok, source_details_return_fixture(%{filename: filename})}
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source_1.id})
|
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source_2.id})
|
|
||||||
perform_job(SourceMetadataStorageWorker, %{id: source_2.id})
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
test "does not blow up if the record doesn't exist" do
|
test "does not set the series directory if it cannot be determined" do
|
||||||
assert :ok = perform_job(SourceMetadataStorageWorker, %{id: 0})
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
|
|
||||||
alias Pinchflat.Sources
|
alias Pinchflat.Sources
|
||||||
alias Pinchflat.Sources.Source
|
alias Pinchflat.Sources.Source
|
||||||
|
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||||
alias Pinchflat.Metadata.MetadataFileHelpers
|
alias Pinchflat.Metadata.MetadataFileHelpers
|
||||||
alias Pinchflat.Downloading.DownloadingHelpers
|
alias Pinchflat.Downloading.DownloadingHelpers
|
||||||
alias Pinchflat.FastIndexing.FastIndexingWorker
|
alias Pinchflat.FastIndexing.FastIndexingWorker
|
||||||
@@ -57,7 +58,7 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
end
|
end
|
||||||
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
|
test "creates a source and adds name + ID from runner response for channels" do
|
||||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||||
|
|
||||||
@@ -137,49 +138,6 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
|
assert {:error, %Ecto.Changeset{}} = Sources.create_source(@invalid_source_attrs)
|
||||||
end
|
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
|
test "creation will schedule the indexing task" do
|
||||||
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
expect(YtDlpRunnerMock, :run, &channel_mock/3)
|
||||||
|
|
||||||
@@ -253,7 +211,23 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
end
|
end
|
||||||
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
|
test "updates with valid data updates the source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
update_attrs = %{collection_name: "some updated name"}
|
update_attrs = %{collection_name: "some updated name"}
|
||||||
@@ -426,6 +400,20 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "delete_source/2" do
|
||||||
test "it deletes the source" do
|
test "it deletes the source" do
|
||||||
source = source_fixture()
|
source = source_fixture()
|
||||||
@@ -474,9 +462,19 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
|
|
||||||
{:ok, updated_source} = Sources.update_source(source, update_attrs)
|
{: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)
|
refute File.exists?(updated_source.metadata.metadata_filepath)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "delete_source/2 when deleting files" do
|
describe "delete_source/2 when deleting files" do
|
||||||
@@ -498,6 +496,15 @@ defmodule Pinchflat.SourcesTest do
|
|||||||
|
|
||||||
refute File.exists?(media_item.media_filepath)
|
refute File.exists?(media_item.media_filepath)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe "change_source/3" do
|
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
|
test "it passes the expected args to the backend runner" do
|
||||||
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
|
expect(YtDlpRunnerMock, :run, fn @channel_url, opts, ot ->
|
||||||
assert opts == [:simulate, :skip_download, :ignore_no_formats_error, playlist_end: 1]
|
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, "{}"}
|
{:ok, "{}"}
|
||||||
end)
|
end)
|
||||||
@@ -151,5 +151,15 @@ defmodule Pinchflat.YtDlp.MediaCollectionTest do
|
|||||||
|
|
||||||
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url)
|
assert {:error, %Jason.DecodeError{}} = MediaCollection.get_source_metadata(@channel_url)
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
|||||||
media_profile: media_profile
|
media_profile: media_profile
|
||||||
} do
|
} do
|
||||||
conn = get(conn, ~p"/media_profiles/#{media_profile}/edit")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ defmodule PinchflatWeb.MediaProfileControllerTest do
|
|||||||
|
|
||||||
test "renders errors when data is invalid", %{conn: conn, media_profile: media_profile} 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)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
|
|
||||||
test "renders form for editing chosen source", %{conn: conn, source: source} do
|
test "renders form for editing chosen source", %{conn: conn, source: source} do
|
||||||
conn = get(conn, ~p"/sources/#{source}/edit")
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ defmodule PinchflatWeb.SourceControllerTest do
|
|||||||
invalid_attrs: invalid_attrs
|
invalid_attrs: invalid_attrs
|
||||||
} do
|
} do
|
||||||
conn = put(conn, ~p"/sources/#{source}", source: invalid_attrs)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 17 KiB |