Compare commits

..

27 Commits

Author SHA1 Message Date
Kieran Eglin 9daef8b3b8 Bumped version 2024-06-10 11:51:15 -07:00
Kieran f6708a327c [Enhancement] Optionally use the YouTube API for improved fast indexing (#282)
* Started adding youtube API for fast indexing

* Hooked youtube API into fast indexing

* Added youtube_api_key to settings

* Added youtube api key to settings UI

* Added tests

* Refactored the youtube api module

* More refactor

* Changed editing mode name from basic to standard

* [WIP] started on copy changes

* Updated copy
2024-06-10 11:45:41 -07:00
Kieran 582eb53698 Add regex for validating source URL (#285) 2024-06-06 11:54:39 -07:00
Kieran a20d06072f Fixed upload_date_index callback from running unnecessarily (#284) 2024-06-05 17:04:49 -07:00
Kieran Eglin 6d18130351 Updated docker release YAML 2024-06-05 09:14:07 -07:00
Kieran af86ca1e0e Switched thumbnail downloading to use yt-dlp (#281) 2024-06-04 10:39:54 -07:00
Kieran 4994e70652 [Bugfix] Partially revert custom codec selection (#279)
* Removed and re-added codec preference columns

* Removed custom codec work from download options builder

* Updated settings UI

* Made codec preferences non-optional fields
2024-06-04 09:19:37 -07:00
Kieran Eglin 1c9f8f6abc Bumped version 2024-05-30 15:16:33 -07:00
Kieran Eglin 2ae1ee1c99 Updated portainer instructions to use GHCR 2024-05-30 15:16:15 -07:00
Kieran b73a9a4a67 [Enhancement] Added support for subdirectory-style reverse proxies (#276)
* Updated routes and other fun stuff to support reverse proxies

* Updated README

* Linting
2024-05-30 12:58:51 -07:00
Kieran 3dd901ff3e Prevented error when media item doesn't have description (#274) 2024-05-29 13:52:43 -07:00
Kieran e751c65424 [Enhancement] Improve handling of large media collections (#272)
* Added compound index for updated media item table for performance

* Improved large number display on homepage

* Improved UI around large numbers

* Centered the content of homepage data cards

* Renamed migration
2024-05-29 12:38:03 -07:00
Kieran 22439e0273 [Housekeeping] Store the upload event as a datetime rather than a date (#269)
* [WIP] renamed and migrated upload_date column

* Refactored yt-dlp media module

* Refactored parse_upload_date

* Refactored media item upload_date_index

* Got media tests running

* Refactored media item table live

* Cleaned up the stragglers

* Fixed old oversight re: original_url
2024-05-28 13:01:40 -07:00
Kieran Eglin 7603ba017f Improved wording re: livestreams on media profile form 2024-05-27 11:15:11 -07:00
Kieran 10880e9699 [Enhancement] Added link to local stream on media item show page (#265)
* Adds link for opening local stream

* Put conditional on correct element
2024-05-27 11:07:23 -07:00
Kieran b2e16d50cd Added library size to homepage (#264) 2024-05-27 10:43:50 -07:00
Kieran 3a5c06fe64 Added upload date (#260) 2024-05-24 10:04:37 -07:00
Kieran Eglin 99fc2eb8f8 Bumped version 2024-05-24 09:38:06 -07:00
Kieran Eglin e824b1a9f5 Fixed header height bug 2024-05-23 15:25:57 -07:00
Kieran 95a0c29358 [Enhancement] Added search to source forms (#259)
* Changed sqlite FTS to use a trigram tokenizer

* Added search UI to source tables

* Fix bug with special chars in search form

* improved main search results form

* Improved centering for media table header elements
2024-05-23 15:09:49 -07:00
Kieran Eglin 8b0b41186a Added code comments 2024-05-23 10:28:08 -07:00
Kieran d2f91a8253 [Enhancement] Added "other" tab to see media that's not set for download (#258)
* Added tab for other (non-downloaded and non-pending) media

* Added column for tracking if media was manually ignored
2024-05-23 10:24:52 -07:00
Kieran Eglin 776b84c585 Switched back to mainline sqleton [skip-ci] 2024-05-23 10:15:35 -07:00
Kieran Eglin 29803c9b26 added bullet points to diag info string 2024-05-22 16:52:32 -07:00
Kieran Eglin 85be8744d4 Linting 2024-05-22 16:51:08 -07:00
Kieran Eglin 1cbc62dc27 [Bugfix] removed layout flash for config dropdown 2024-05-22 14:45:44 -07:00
Kieran 5af22d3a2f Update issue templates 2024-05-22 14:27:04 -07:00
84 changed files with 1532 additions and 823 deletions
+34
View File
@@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report to help us improve
title: '[Triage] <your title here> '
labels: triage
assignees: kieraneglin
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Diagnostic info**
<!-- Go to Config > App Info > Copy Diagnostic Info and paste that here -->
**Additional context**
<!-- Go to Config > App Info > Download Logs and attach them, if applicable -->
Add any other context about the problem here.
+19
View File
@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FR] <your title here>'
labels: feature request
assignees: kieraneglin
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. It's too complicated to [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+7
View File
@@ -0,0 +1,7 @@
---
name: Other
about: For everything else
title: ''
labels: ''
assignees: kieraneglin
---
+8 -1
View File
@@ -11,6 +11,12 @@ on:
options: options:
- 'linux/amd64' - 'linux/amd64'
- 'linux/amd64,linux/arm64' - 'linux/amd64,linux/arm64'
docker_tags:
type: string
description: 'Docker Tags'
required: true
default: 'dev'
push: push:
branches: branches:
- master - master
@@ -41,7 +47,8 @@ jobs:
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch) # All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
tags: | tags: |
type=ref,event=tag type=ref,event=tag
type=raw,value=dev,enable=${{ github.event_name != 'release' }} type=raw,value=dev,enable=${{ github.event_name != 'release' && github.event_name != 'workflow_dispatch' }}
type=raw,value=${{ inputs.docker_tags }},enable=${{ github.event_name == 'workflow_dispatch' }}
flavor: | flavor: |
latest=auto latest=auto
+2 -1
View File
@@ -91,7 +91,7 @@ Docker Compose file:
version: '3' version: '3'
services: services:
pinchflat: pinchflat:
image: keglin/pinchflat:latest image: ghcr.io/kieraneglin/pinchflat:latest
environment: environment:
# Set the timezone to your local timezone # Set the timezone to your local timezone
- TZ=America/New_York - TZ=America/New_York
@@ -153,6 +153,7 @@ If you change this setting and it works well for you, please leave a comment on
| EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) | | EXPOSE_FEED_ENDPOINTS | No | | See [RSS feed docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds) |
| JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) | | JOURNAL_MODE | No | `wal` | Set to `delete` if your config directory is stored on a network share (not recommended) |
| TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored | | TZ_DATA_DIR | No | `/etc/elixir_tzdata_data` | The container path where the timezone database is stored |
| BASE_ROUTE_PATH | No | `/` | The base path for route generation. Useful when running behind certain reverse proxies |
## EFF donations ## EFF donations
+21 -18
View File
@@ -22,8 +22,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-Light.woff2'), url('/fonts/satoshi/Satoshi-Light.woff'), src: url('../fonts/satoshi/Satoshi-Light.woff2'), url('../fonts/satoshi/Satoshi-Light.woff'),
url('/fonts/satoshi/Satoshi-Light.ttf'); url('../fonts/satoshi/Satoshi-Light.ttf');
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
@@ -31,8 +31,9 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-LightItalic.woff2'), src: url('../fonts/satoshi/Satoshi-LightItalic.woff2'),
url('/fonts/satoshi/Satoshi-LightItalic.woff'), url('/fonts/satoshi/Satoshi-LightItalic.ttf'); url('../fonts/satoshi/Satoshi-LightItalic.woff'),
url('../fonts/satoshi/Satoshi-LightItalic.ttf');
font-weight: 300; font-weight: 300;
font-display: swap; font-display: swap;
font-style: italic; font-style: italic;
@@ -40,8 +41,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-Regular.woff2'), url('/fonts/satoshi/Satoshi-Regular.woff'), src: url('../fonts/satoshi/Satoshi-Regular.woff2'), url('../fonts/satoshi/Satoshi-Regular.woff'),
url('/fonts/satoshi/Satoshi-Regular.ttf'); url('../fonts/satoshi/Satoshi-Regular.ttf');
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
@@ -49,8 +50,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-Italic.woff2'), url('/fonts/satoshi/Satoshi-Italic.woff'), src: url('../fonts/satoshi/Satoshi-Italic.woff2'), url('../fonts/satoshi/Satoshi-Italic.woff'),
url('/fonts/satoshi/Satoshi-Italic.ttf'); url('../fonts/satoshi/Satoshi-Italic.ttf');
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
font-style: italic; font-style: italic;
@@ -58,8 +59,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-Medium.woff2'), url('/fonts/satoshi/Satoshi-Medium.woff'), src: url('../fonts/satoshi/Satoshi-Medium.woff2'), url('../fonts/satoshi/Satoshi-Medium.woff'),
url('/fonts/satoshi/Satoshi-Medium.ttf'); url('../fonts/satoshi/Satoshi-Medium.ttf');
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
@@ -67,8 +68,9 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-MediumItalic.woff2'), src: url('../fonts/satoshi/Satoshi-MediumItalic.woff2'),
url('/fonts/satoshi/Satoshi-MediumItalic.woff'), url('/fonts/satoshi/Satoshi-MediumItalic.ttf'); url('../fonts/satoshi/Satoshi-MediumItalic.woff'),
url('../fonts/satoshi/Satoshi-MediumItalic.ttf');
font-weight: 500; font-weight: 500;
font-display: swap; font-display: swap;
font-style: italic; font-style: italic;
@@ -76,8 +78,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-Bold.woff2'), url('/fonts/satoshi/Satoshi-Bold.woff'), src: url('../fonts/satoshi/Satoshi-Bold.woff2'), url('../fonts/satoshi/Satoshi-Bold.woff'),
url('/fonts/satoshi/Satoshi-Bold.ttf'); url('../fonts/satoshi/Satoshi-Bold.ttf');
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
font-style: normal; font-style: normal;
@@ -85,8 +87,8 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-BoldItalic.woff2'), url('/fonts/satoshi/Satoshi-BoldItalic.woff'), src: url('../fonts/satoshi/Satoshi-BoldItalic.woff2'),
url('/fonts/satoshi/Satoshi-BoldItalic.ttf'); url('../fonts/satoshi/Satoshi-BoldItalic.woff'), url('../fonts/satoshi/Satoshi-BoldItalic.ttf');
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
font-style: italic; font-style: italic;
@@ -94,8 +96,9 @@
@font-face { @font-face {
font-family: 'Satoshi'; font-family: 'Satoshi';
src: url('/fonts/satoshi/Satoshi-BlackItalic.woff2'), src: url('../fonts/satoshi/Satoshi-BlackItalic.woff2'),
url('/fonts/satoshi/Satoshi-BlackItalic.woff'), url('/fonts/satoshi/Satoshi-BlackItalic.ttf'); url('../fonts/satoshi/Satoshi-BlackItalic.woff'),
url('../fonts/satoshi/Satoshi-BlackItalic.ttf');
font-weight: 900; font-weight: 900;
font-display: swap; font-display: swap;
font-style: italic; font-style: italic;
+1 -1
View File
@@ -29,7 +29,7 @@ window.Alpine = Alpine
Alpine.start() Alpine.start()
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
let liveSocket = new LiveSocket('/live', Socket, { let liveSocket = new LiveSocket(document.body.dataset.socketPath, Socket, {
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },
dom: { dom: {
onBeforeElUpdated(from, to) { onBeforeElUpdated(from, to) {
+2 -1
View File
@@ -26,7 +26,8 @@ config :pinchflat,
basic_auth_password: "", basic_auth_password: "",
expose_feed_endpoints: false, expose_feed_endpoints: false,
file_watcher_poll_interval: 1000, file_watcher_poll_interval: 1000,
timezone: "UTC" timezone: "UTC",
base_route_path: "/"
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
journal_mode: :wal, journal_mode: :wal,
+5 -3
View File
@@ -46,12 +46,12 @@ if config_env() == :prod do
log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"])) log_path = System.get_env("LOG_PATH", Path.join([config_path, "logs", "pinchflat.log"]))
metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"])) metadata_path = System.get_env("METADATA_PATH", Path.join([config_path, "metadata"]))
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"])) extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
# For running PF as a podcast host on self-hosted environments # For running PF as a podcast host on self-hosted environments
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0 expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
# For testing alternate journal modes (see issue #137) # For testing alternate journal modes (see issue #137)
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal")) journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
# For running PF in a subdirectory via a reverse proxy
base_route_path = System.get_env("BASE_ROUTE_PATH", "/")
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug")) config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "debug"))
@@ -65,7 +65,8 @@ if config_env() == :prod do
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints, expose_feed_endpoints: expose_feed_endpoints,
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC", timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC",
log_path: log_path log_path: log_path,
base_route_path: base_route_path
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data") config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data")
@@ -108,6 +109,7 @@ if config_env() == :prod do
ip: {0, 0, 0, 0}, ip: {0, 0, 0, 0},
port: String.to_integer(System.get_env("PORT") || "4000") port: String.to_integer(System.get_env("PORT") || "4000")
], ],
url: [path: base_route_path],
secret_key_base: secret_key_base secret_key_base: secret_key_base
config :pinchflat, :logger, [ config :pinchflat, :logger, [
+2
View File
@@ -12,3 +12,5 @@ services:
- ./docker-run.dev.sh - ./docker-run.dev.sh
stdin_open: true stdin_open: true
tty: true tty: true
env_file:
- .env
-82
View File
@@ -1,82 +0,0 @@
defmodule Pinchflat.Downloading.CodecParser do
@moduledoc """
Functions for generating yt-dlp codec strings
"""
alias Pinchflat.Settings
@doc """
Generate a video codec string based on the value of the video_codec_preference setting.
Returns binary()
"""
def generate_vcodec_string_from_settings do
generate_vcodec_string(Settings.get!(:video_codec_preference))
end
@doc """
Generate an audio codec string based on the value of the audio_codec_preference setting.
Returns binary()
"""
def generate_acodec_string_from_settings do
generate_acodec_string(Settings.get!(:audio_codec_preference))
end
@doc """
Generate a video codec string from a list of video codecs.
If the list is nil or empty, the default video codec is AVC.
Returns binary()
"""
def generate_vcodec_string(nil), do: "bestvideo[vcodec~='^avc']/bestvideo"
def generate_vcodec_string([]), do: generate_vcodec_string(nil)
def generate_vcodec_string(video_codecs) do
video_codecs
|> Enum.map(&video_codec_map()[&1])
|> Enum.reject(&is_nil/1)
|> Enum.map(&"bestvideo[vcodec~='^#{&1}']")
|> Enum.concat(["bestvideo"])
|> Enum.join("/")
end
@doc """
Generate an audio codec string from a list of audio codecs.
If the list is nil or empty, the default audio codec is MP4A.
Returns binary()
"""
def generate_acodec_string(nil), do: "bestaudio[acodec~='^mp4a']/bestaudio"
def generate_acodec_string([]), do: generate_acodec_string(nil)
def generate_acodec_string(audio_codecs) do
audio_codecs
|> Enum.map(&audio_codec_map()[&1])
|> Enum.reject(&is_nil/1)
|> Enum.map(&"bestaudio[acodec~='^#{&1}']")
|> Enum.concat(["bestaudio"])
|> Enum.join("/")
end
@doc false
def video_codec_map do
%{
"av01" => "av01",
"avc" => "avc",
"vp9" => "vp0?9"
}
end
@doc false
def audio_codec_map do
%{
"aac" => "aac",
"mp4a" => "mp4a",
"mp3" => "mp3",
"opus" => "opus"
}
end
end
@@ -4,9 +4,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
""" """
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Settings
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.CodecParser
alias Pinchflat.Downloading.OutputPathBuilder alias Pinchflat.Downloading.OutputPathBuilder
alias Pinchflat.Utils.FilesystemUtils, as: FSUtils alias Pinchflat.Utils.FilesystemUtils, as: FSUtils
@@ -122,13 +122,13 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
defp quality_options(media_profile) do defp quality_options(media_profile) do
vcodec_string = CodecParser.generate_vcodec_string_from_settings() vcodec = Settings.get!(:video_codec_preference)
acodec_string = CodecParser.generate_acodec_string_from_settings() acodec = Settings.get!(:audio_codec_preference)
case media_profile.preferred_resolution do case media_profile.preferred_resolution do
# Also be aware that :audio disabled all embedding options for subtitles # Also be aware that :audio disabled all embedding options for subtitles
:audio -> :audio ->
[:extract_audio, format: "#{acodec_string}/best"] [:extract_audio, format_sort: "+acodec:#{acodec}"]
resolution_atom -> resolution_atom ->
{resolution_string, _} = {resolution_string, _} =
@@ -137,10 +137,9 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|> Integer.parse() |> Integer.parse()
[ [
format_sort: "res:#{resolution_string}",
# Since Plex doesn't support reading metadata from MKV # Since Plex doesn't support reading metadata from MKV
remux_video: "mp4", remux_video: "mp4",
format: "((#{vcodec_string})+(#{acodec_string}))/best" format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
] ]
end end
end end
@@ -84,7 +84,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
# IDEA: might be worth kicking off a job for this since thumbnail fetching # IDEA: might be worth kicking off a job for this since thumbnail fetching
# could fail and I want to handle that in isolation # could fail and I want to handle that in isolation
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json), metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_with_preloads, parsed_json),
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads, parsed_json) thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_with_preloads)
} }
}) })
@@ -13,12 +13,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.FastIndexing.YoutubeApi
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.YtDlp.Media, as: YtDlpMedia alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
Fetches new media IDs from a source's YouTube RSS feed, indexes them, and kicks off downloading Fetches new media IDs for a source from YT's API or RSS, indexes them, and kicks off downloading
tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the tasks for any pending media items. See comments in `FastIndexingWorker` for more info on the
order of operations and how this fits into the indexing process. order of operations and how this fits into the indexing process.
@@ -26,7 +27,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
downloaded_. downloaded_.
""" """
def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_download_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source) {:ok, media_ids} = get_recent_media_ids(source)
existing_media_items = list_media_items_by_media_id_for(source, media_ids) existing_media_items = list_media_items_by_media_id_for(source, media_ids)
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id) new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
@@ -47,6 +48,17 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
Enum.filter(maybe_new_media_items, & &1) Enum.filter(maybe_new_media_items, & &1)
end end
# If possible, use the YouTube API to fetch media IDs. If that fails, fall back to the RSS feed.
# If the YouTube API isn't set up, just use the RSS feed.
defp get_recent_media_ids(source) do
with true <- YoutubeApi.enabled?(),
{:ok, media_ids} <- YoutubeApi.get_recent_media_ids(source) do
{:ok, media_ids}
else
_ -> YoutubeRss.get_recent_media_ids(source)
end
end
defp list_media_items_by_media_id_for(source, media_ids) do defp list_media_items_by_media_id_for(source, media_ids) do
MediaQuery.new() MediaQuery.new()
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids)) |> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
@@ -0,0 +1,92 @@
defmodule Pinchflat.FastIndexing.YoutubeApi do
@moduledoc """
Methods for interacting with the YouTube API for fast indexing
"""
require Logger
alias Pinchflat.Settings
alias Pinchflat.Sources.Source
alias Pinchflat.Utils.FunctionUtils
alias Pinchflat.FastIndexing.YoutubeBehaviour
@behaviour YoutubeBehaviour
@doc """
Determines if the YouTube API is enabled for fast indexing by checking
if the user has an API key set
Returns boolean()
"""
@impl YoutubeBehaviour
def enabled?(), do: is_binary(api_key())
@doc """
Fetches the recent media IDs from the YouTube API for a given source.
Returns {:ok, [binary()]} | {:error, binary()}
"""
@impl YoutubeBehaviour
def get_recent_media_ids(%Source{} = source) do
api_response =
source
|> determine_playlist_id()
|> do_api_request()
case api_response do
{:ok, parsed_json} -> get_media_ids_from_response(parsed_json)
{:error, reason} -> {:error, reason}
end
end
# The UC prefix is for channels which won't work with this API endpoint. Swapping
# the prefix to UU will get us the playlist that represents the channel's uploads
defp determine_playlist_id(%{collection_id: c_id}) do
String.replace_prefix(c_id, "UC", "UU")
end
defp do_api_request(playlist_id) do
Logger.debug("Fetching recent media IDs from YouTube API for playlist: #{playlist_id}")
playlist_id
|> construct_api_endpoint()
|> http_client().get(accept: "application/json")
|> case do
{:ok, response} ->
Phoenix.json_library().decode(response)
{:error, reason} ->
Logger.error("Failed to fetch YouTube API: #{inspect(reason)}")
{:error, reason}
end
end
defp get_media_ids_from_response(parsed_json) do
parsed_json
|> Map.get("items", [])
|> Enum.map(fn item ->
item
|> Map.get("contentDetails", %{})
|> Map.get("videoId", nil)
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> FunctionUtils.wrap_ok()
end
defp api_key do
Settings.get!(:youtube_api_key)
end
defp construct_api_endpoint(playlist_id) do
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
property_type = "contentDetails"
max_results = 50
"#{api_base}?part=#{property_type}&maxResults=#{max_results}&playlistId=#{playlist_id}&key=#{api_key()}"
end
defp http_client do
Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
end
end
@@ -0,0 +1,11 @@
defmodule Pinchflat.FastIndexing.YoutubeBehaviour do
@moduledoc """
This module defines the behaviour for clients that interface with YouTube
for the purpose of fast indexing.
"""
alias Pinchflat.Sources.Source
@callback enabled?() :: boolean()
@callback get_recent_media_ids(%Source{}) :: {:ok, [String.t()]} | {:error, String.t()}
end
+15 -2
View File
@@ -1,18 +1,31 @@
defmodule Pinchflat.FastIndexing.YoutubeRss do defmodule Pinchflat.FastIndexing.YoutubeRss do
@moduledoc """ @moduledoc """
Methods for interacting with YouTube RSS feeds Methods for interacting with YouTube RSS feeds for fast indexing
""" """
require Logger require Logger
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.FastIndexing.YoutubeBehaviour
@behaviour YoutubeBehaviour
@doc """
Determines if the YouTube RSS feed is enabled for fast indexing. Used to satisfy
the `YoutubeBehaviour` behaviour.
Returns true
"""
@impl YoutubeBehaviour
def enabled?(), do: true
@doc """ @doc """
Fetches the recent media IDs from a YouTube RSS feed for a given source. Fetches the recent media IDs from a YouTube RSS feed for a given source.
Returns {:ok, [binary()]} | {:error, binary()} Returns {:ok, [binary()]} | {:error, binary()}
""" """
def get_recent_media_ids_from_rss(%Source{} = source) do @impl YoutubeBehaviour
def get_recent_media_ids(%Source{} = source) do
Logger.debug("Fetching recent media IDs from YouTube RSS feed for source: #{source.collection_id}") Logger.debug("Fetching recent media IDs from YouTube RSS feed for source: #{source.collection_id}")
case http_client().get(rss_url_for_source(source)) do case http_client().get(rss_url_for_source(source)) do
+7 -1
View File
@@ -21,9 +21,11 @@ defmodule Pinchflat.HTTP.HTTPClient do
""" """
@impl HTTPBehaviour @impl HTTPBehaviour
def get(url, headers \\ [], opts \\ []) do def get(url, headers \\ [], opts \\ []) do
headers = parse_headers(headers)
case :httpc.request(:get, {url, headers}, [], opts) do case :httpc.request(:get, {url, headers}, [], opts) do
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} -> {:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
{:ok, body} {:ok, to_string(body)}
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} -> {:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
{:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"} {:error, "HTTP request failed with status code #{status_code}: #{reason_phrase}"}
@@ -32,4 +34,8 @@ defmodule Pinchflat.HTTP.HTTPClient do
{:error, "HTTP request failed: #{reason}"} {:error, "HTTP request failed: #{reason}"}
end end
end end
defp parse_headers(headers) do
Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
end
end end
+5 -4
View File
@@ -41,9 +41,10 @@ defmodule Pinchflat.Media do
Returns a list of media_items that are redownloadable based on the redownload delay Returns a list of media_items that are redownloadable based on the redownload delay
of the media_profile their source belongs to. of the media_profile their source belongs to.
The logic is that a media_item is past_redownload_delay if the media_item's The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
upload_date is at least redownload_delay_days ago AND at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`. is before the media_item's `uploaded_at`.
This logic grabs media that we've recently downloaded AND is recently uploaded, but This logic grabs media that we've recently downloaded AND is recently uploaded, but
doesn't grab media that we've recently downloaded and was uploaded a long time ago. doesn't grab media that we've recently downloaded and was uploaded a long time ago.
This also makes things work as expected when downloading media from a source for the This also makes things work as expected when downloading media from a source for the
@@ -135,7 +136,7 @@ defmodule Pinchflat.Media do
Unlike `create_media_item`, this will attempt an update if the media_item Unlike `create_media_item`, this will attempt an update if the media_item
already exists. This is so that future indexing can pick up attributes that already exists. This is so that future indexing can pick up attributes that
we may not have asked for in the past (eg: upload_date) we may not have asked for in the past (eg: uploaded_at)
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
""" """
+30 -6
View File
@@ -26,7 +26,7 @@ defmodule Pinchflat.Media.MediaItem do
:livestream, :livestream,
:source_id, :source_id,
:short_form_content, :short_form_content,
:upload_date, :uploaded_at,
:upload_date_index, :upload_date_index,
:duration_seconds, :duration_seconds,
# these fields are captured only on download # these fields are captured only on download
@@ -51,7 +51,7 @@ defmodule Pinchflat.Media.MediaItem do
livestream livestream
media_id media_id
source_id source_id
upload_date uploaded_at
short_form_content short_form_content
)a )a
@@ -69,7 +69,7 @@ defmodule Pinchflat.Media.MediaItem do
field :short_form_content, :boolean, default: false field :short_form_content, :boolean, default: false
field :media_downloaded_at, :utc_datetime field :media_downloaded_at, :utc_datetime
field :media_redownloaded_at, :utc_datetime field :media_redownloaded_at, :utc_datetime
field :upload_date, :date field :uploaded_at, :utc_datetime
field :upload_date_index, :integer, default: 0 field :upload_date_index, :integer, default: 0
field :duration_seconds, :integer field :duration_seconds, :integer
@@ -130,7 +130,31 @@ defmodule Pinchflat.Media.MediaItem do
~w(__meta__ __struct__ metadata tasks media_items_search_index)a ~w(__meta__ __struct__ metadata tasks media_items_search_index)a
end end
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :upload_date) do # Run it on new records no matter what. The method we delegate to
# will handle the case where `uploaded_at` is `nil`
defp update_upload_date_index(%{data: %{id: nil}} = changeset) do
do_update_upload_date_index(changeset)
end
# For the update case, we only want to recalculate if the day itself has changed.
# For instance, this is useful in the migration from `upload_date` to `uploaded_at`
defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
old_uploaded_at = changeset.data.uploaded_at
new_uploaded_at = get_change(changeset, :uploaded_at)
upload_dates_match = DateTime.to_date(old_uploaded_at) == DateTime.to_date(new_uploaded_at)
if upload_dates_match do
changeset
else
do_update_upload_date_index(changeset)
end
end
# If the record is persisted and the `uploaded_at` field is not being changed,
# we don't need to recalculate the index.
defp update_upload_date_index(changeset), do: changeset
defp do_update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
source_id = get_field(changeset, :source_id) source_id = get_field(changeset, :source_id)
source = Sources.get_source!(source_id) source = Sources.get_source!(source_id)
# Channels should count down from 99, playlists should count up from 0 # Channels should count down from 99, playlists should count up from 0
@@ -142,7 +166,7 @@ defmodule Pinchflat.Media.MediaItem do
current_max = current_max =
MediaQuery.new() MediaQuery.new()
|> where(^dynamic([mi], mi.upload_date == ^changes.upload_date and ^MediaQuery.for_source(source))) |> where(^dynamic([mi], ^MediaQuery.upload_date_matches(changes.uploaded_at) and ^MediaQuery.for_source(source)))
|> Repo.aggregate(aggregator, :upload_date_index) |> Repo.aggregate(aggregator, :upload_date_index)
case current_max do case current_max do
@@ -151,7 +175,7 @@ defmodule Pinchflat.Media.MediaItem do
end end
end end
defp update_upload_date_index(changeset), do: changeset defp do_update_upload_date_index(changeset), do: changeset
defimpl Jason.Encoder, for: MediaItem do defimpl Jason.Encoder, for: MediaItem do
def encode(value, opts) do def encode(value, opts) do
+50 -5
View File
@@ -35,9 +35,14 @@ defmodule Pinchflat.Media.MediaQuery do
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true) def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
def culled, do: dynamic([mi], not is_nil(mi.culled_at)) def culled, do: dynamic([mi], not is_nil(mi.culled_at))
def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at)) def redownloaded, do: dynamic([mi], not is_nil(mi.media_redownloaded_at))
def upload_date_matches(other_date), do: dynamic([mi], fragment("date(?) = date(?)", mi.uploaded_at, ^other_date))
def upload_date_after_source_cutoff do def upload_date_after_source_cutoff do
dynamic([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date) dynamic(
[mi, source],
is_nil(source.download_cutoff_date) or
fragment("date(?) >= ?", mi.uploaded_at, source.download_cutoff_date)
)
end end
def format_matching_profile_preference do def format_matching_profile_preference do
@@ -84,12 +89,12 @@ defmodule Pinchflat.Media.MediaQuery do
def past_redownload_delay do def past_redownload_delay do
dynamic( dynamic(
[mi, source, media_profile], [mi, source, media_profile],
# Returns media items where the upload_date is at least redownload_delay_days ago AND # Returns media items where the uploaded_at is at least redownload_delay_days ago AND
# downloaded_at minus the redownload_delay_days is before the upload date # downloaded_at minus the redownload_delay_days is before the upload date
fragment(""" fragment("""
IFNULL(redownload_delay_days, 0) > 0 AND IFNULL(redownload_delay_days, 0) > 0 AND
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
""") """)
) )
end end
@@ -125,6 +130,18 @@ defmodule Pinchflat.Media.MediaQuery do
) )
end end
def matches_search_term(nil), do: dynamic([mi], true)
def matches_search_term(term) do
escaped_term = clean_search_term(term)
# Matching on `term` instead of `escaped_term` because the latter can mangle empty strings
case String.trim(term) do
"" -> dynamic([mi], true)
_ -> dynamic([mi], fragment("media_items_search_index MATCH ?", ^escaped_term))
end
end
def require_assoc(query, identifier) do def require_assoc(query, identifier) do
if has_named_binding?(query, identifier) do if has_named_binding?(query, identifier) do
query query
@@ -133,6 +150,10 @@ defmodule Pinchflat.Media.MediaQuery do
end end
end end
defp do_require_assoc(query, :media_items_search_index) do
from(mi in query, join: s in assoc(mi, :media_items_search_index), as: :media_items_search_index)
end
defp do_require_assoc(query, :source) do defp do_require_assoc(query, :source) do
from(mi in query, join: s in assoc(mi, :source), as: :source) from(mi in query, join: s in assoc(mi, :source), as: :source)
end end
@@ -148,9 +169,11 @@ defmodule Pinchflat.Media.MediaQuery do
def matching_search_term(query, nil), do: query def matching_search_term(query, nil), do: query
def matching_search_term(query, term) do def matching_search_term(query, term) do
escaped_term = clean_search_term(term)
from(mi in query, from(mi in query,
join: mi_search_index in assoc(mi, :media_items_search_index), join: mi_search_index in assoc(mi, :media_items_search_index),
where: fragment("media_items_search_index MATCH ?", ^term), where: fragment("media_items_search_index MATCH ?", ^escaped_term),
select_merge: %{ select_merge: %{
matching_search_term: matching_search_term:
fragment(""" fragment("""
@@ -162,4 +185,26 @@ defmodule Pinchflat.Media.MediaQuery do
order_by: [desc: fragment("rank")] order_by: [desc: fragment("rank")]
) )
end end
# SQLite's FTS5 is very picky about what it will accept as a search term.
# To that end, we need to clean up the search term before passing it to the
# MATCH clause.
# This method:
# - Trims leading and trailing whitespace
# - Collapses multiple spaces into a single space
# - Removes quote characters
# - Wraps any word in quotes (must happen after the double quote replacement)
#
# This allows for works with apostrophes and quotes to be searched for correctly
defp clean_search_term(nil), do: ""
defp clean_search_term(""), do: ""
defp clean_search_term(term) do
term
|> String.trim()
|> String.replace(~r/\s+/, " ")
|> String.split(~r/\s+/)
|> Enum.map(fn str -> String.replace(str, ~s("), "") end)
|> Enum.map_join(" ", fn str -> ~s("#{str}") end)
end
end end
+17 -35
View File
@@ -11,6 +11,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
alias Pinchflat.Utils.FilesystemUtils alias Pinchflat.Utils.FilesystemUtils
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
Returns the directory where metadata for a database record should be stored. Returns the directory where metadata for a database record should be stored.
@@ -54,48 +56,35 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
@doc """ @doc """
Downloads and stores a thumbnail for a media item, returning the filepath. Downloads and stores a thumbnail for a media item, returning the filepath.
Chooses the highest quality thumbnail available (preferring jpg). Returns Chooses the highest quality thumbnail available and converts it to a JPG
nil if no thumbnails are available.
Returns nil if no thumbnail is available or if yt-dlp encounters an error
Returns binary() | nil Returns binary() | nil
""" """
def download_and_store_thumbnail_for(database_record, metadata_map) do def download_and_store_thumbnail_for(database_record) do
thumbnails = yt_dlp_filepath = generate_filepath_for(database_record, "thumbnail.%(ext)s")
(metadata_map["thumbnails"] || []) real_filepath = generate_filepath_for(database_record, "thumbnail.jpg")
# Give it a low preference if the `preference` key doesn't exist
|> Enum.map(&Map.put_new(&1, "preference", -1000))
# Give it a low preference if image isn't a jpg
|> Enum.map(fn t ->
preference_weight = if String.ends_with?(t["url"], ".jpg"), do: t["preference"], else: t["preference"] - 1000
Map.put(t, "preference", preference_weight) case YtDlpMedia.download_thumbnail(database_record.original_url, output: yt_dlp_filepath) do
end) {:ok, _} -> real_filepath
_ -> nil
case Enum.sort_by(thumbnails, & &1["preference"], :desc) do
[thumbnail_map | _] ->
thumbnail_url = thumbnail_map["url"]
filepath = generate_filepath_for(database_record, Path.basename(thumbnail_url))
thumbnail_blob = fetch_thumbnail_from_url(thumbnail_url)
:ok = FilesystemUtils.write_p!(filepath, thumbnail_blob)
filepath
_ ->
nil
end end
end end
@doc """ @doc """
Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata Parses an upload date from the YYYYMMDD string returned in yt-dlp metadata
and returns a Date struct. and returns a DateTime struct, appending a time of 00:00:00Z.
Returns Date.t() Returns DateTime.t()
""" """
def parse_upload_date(upload_date) do def parse_upload_date(upload_date) do
<<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date <<year::binary-size(4)>> <> <<month::binary-size(2)>> <> <<day::binary-size(2)>> = upload_date
Date.from_iso8601!("#{year}-#{month}-#{day}") case DateTime.from_iso8601("#{year}-#{month}-#{day}T00:00:00Z") do
{:ok, datetime, _} -> datetime
_ -> raise "Invalid upload date: #{upload_date}"
end
end end
@doc """ @doc """
@@ -135,13 +124,6 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
end end
end end
defp fetch_thumbnail_from_url(url) do
http_client = Application.get_env(:pinchflat, :http_client, Pinchflat.HTTP.HTTPClient)
{:ok, body} = http_client.get(url, [], body_format: :binary)
body
end
defp generate_filepath_for(database_record, filename) do defp generate_filepath_for(database_record, filename) do
Path.join([ Path.join([
metadata_directory_for(database_record), metadata_directory_for(database_record),
+8 -9
View File
@@ -8,6 +8,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
and not have it, ya know? and not have it, ya know?
""" """
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
@doc """ @doc """
Parses the given JSON response from yt-dlp and returns a map of Parses the given JSON response from yt-dlp and returns a map of
the needful media_item attributes, along with anything needed for the needful media_item attributes, along with anything needed for
@@ -24,15 +26,12 @@ defmodule Pinchflat.Metadata.MetadataParser do
end end
defp parse_media_metadata(metadata) do defp parse_media_metadata(metadata) do
%{ Map.merge(
media_id: metadata["id"], Map.from_struct(YtDlpMedia.response_to_struct(metadata)),
title: metadata["title"], %{
original_url: metadata["original_url"], media_filepath: metadata["filepath"]
description: metadata["description"], }
media_filepath: metadata["filepath"], )
livestream: !!metadata["was_live"],
duration_seconds: metadata["duration"] && round(metadata["duration"])
}
end end
defp parse_subtitle_metadata(metadata) do defp parse_subtitle_metadata(metadata) do
+1 -1
View File
@@ -28,7 +28,7 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
MediaQuery.new() MediaQuery.new()
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded())) |> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|> order_by(desc: :upload_date) |> order_by(desc: :uploaded_at)
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end) |> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
+1 -8
View File
@@ -7,7 +7,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
import Pinchflat.Utils.XmlUtils, only: [safe: 1] import Pinchflat.Utils.XmlUtils, only: [safe: 1]
alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes alias PinchflatWeb.Router.Helpers, as: Routes
@@ -83,7 +82,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
<title>#{safe(media_item.title)}</title> <title>#{safe(media_item.title)}</title>
<link>#{safe(media_item.original_url)}</link> <link>#{safe(media_item.original_url)}</link>
<description>#{safe(media_item.description)}</description> <description>#{safe(media_item.description)}</description>
<pubDate>#{generate_upload_date(media_item)}</pubDate> <pubDate>#{Calendar.strftime(media_item.uploaded_at, @datetime_format)}</pubDate>
<itunes:duration>#{media_item.duration_seconds}</itunes:duration> <itunes:duration>#{media_item.duration_seconds}</itunes:duration>
<enclosure <enclosure
url="#{media_stream_path(url_base, media_item)}" url="#{media_stream_path(url_base, media_item)}"
@@ -133,12 +132,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
end end
end end
defp generate_upload_date(media_item) do
media_item.upload_date
|> DatetimeUtils.date_to_datetime()
|> Calendar.strftime(@datetime_format)
end
defp podcast_route(action, params) do defp podcast_route(action, params) do
Routes.podcast_path(PinchflatWeb.Endpoint, action, params) Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
end end
+7 -36
View File
@@ -13,17 +13,15 @@ defmodule Pinchflat.Settings.Setting do
:apprise_version, :apprise_version,
:apprise_server, :apprise_server,
:video_codec_preference, :video_codec_preference,
:audio_codec_preference :audio_codec_preference,
] :youtube_api_key
@virtual_fields [
:video_codec_preference_string,
:audio_codec_preference_string
] ]
@required_fields ~w( @required_fields ~w(
onboarding onboarding
pro_enabled pro_enabled
video_codec_preference
audio_codec_preference
)a )a
schema "settings" do schema "settings" do
@@ -32,43 +30,16 @@ defmodule Pinchflat.Settings.Setting do
field :yt_dlp_version, :string field :yt_dlp_version, :string
field :apprise_version, :string field :apprise_version, :string
field :apprise_server, :string field :apprise_server, :string
field :youtube_api_key, :string
field :video_codec_preference, {:array, :string}, default: [] field :video_codec_preference, :string
field :audio_codec_preference, {:array, :string}, default: [] field :audio_codec_preference, :string
field :video_codec_preference_string, :string, default: nil, virtual: true
field :audio_codec_preference_string, :string, default: nil, virtual: true
end end
@doc false @doc false
def changeset(setting, attrs) do def changeset(setting, attrs) do
setting setting
|> cast(attrs, @allowed_fields) |> cast(attrs, @allowed_fields)
|> cast(attrs, @virtual_fields, empty_values: [])
|> convert_codec_preference_strings()
|> validate_required(@required_fields) |> validate_required(@required_fields)
end end
defp convert_codec_preference_strings(changeset) do
fields = [
video_codec_preference_string: :video_codec_preference,
audio_codec_preference_string: :audio_codec_preference
]
Enum.reduce(fields, changeset, fn {virtual_field, actual_field}, changeset ->
case get_change(changeset, virtual_field) do
nil ->
changeset
value ->
new_value =
value
|> String.split(">")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(String.trim(&1) == ""))
|> Enum.map(&String.downcase/1)
put_change(changeset, actual_field, new_value)
end
end)
end
end end
+9 -1
View File
@@ -115,6 +115,7 @@ defmodule Pinchflat.Sources.Source do
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0) |> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room) # Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}") |> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|> validate_format(:original_url, youtube_channel_or_playlist_regex(), message: "must be a channel or playlist URL")
|> 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, :title_filter_regex], error_key: :original_url) |> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
end end
@@ -128,7 +129,7 @@ defmodule Pinchflat.Sources.Source do
@doc false @doc false
def fast_index_frequency do def fast_index_frequency do
# minutes # minutes
15 10
end end
@doc false @doc false
@@ -141,6 +142,13 @@ defmodule Pinchflat.Sources.Source do
~w(__meta__ __struct__ metadata tasks media_items)a ~w(__meta__ __struct__ metadata tasks media_items)a
end end
def youtube_channel_or_playlist_regex do
# Validate that the original URL is not a video URL
# Also matches if the string does NOT contain youtube.com or youtu.be. This preserves my tenuous support
# for non-youtube sources.
~r<^(?:(?!youtube\.com/(watch|shorts|embed)|youtu\.be).)*$>
end
defimpl Jason.Encoder, for: Source do defimpl Jason.Encoder, for: Source do
def encode(value, opts) do def encode(value, opts) do
value value
-17
View File
@@ -1,17 +0,0 @@
defmodule Pinchflat.Utils.DatetimeUtils do
@moduledoc """
Utility methods for working with dates and datetimes
"""
@doc """
Converts a Date to a DateTime
Returns %DateTime{}
"""
def date_to_datetime(date) do
date
|> Date.to_gregorian_days()
|> Kernel.*(86_400)
|> DateTime.from_gregorian_seconds()
end
end
+23
View File
@@ -13,4 +13,27 @@ defmodule Pinchflat.Utils.NumberUtils do
|> max(minimum) |> max(minimum)
|> min(maximum) |> min(maximum)
end end
@doc """
Converts a number to a human readable byte size. Can take a precision
option to specify the number of decimal places to round to.
Returns {integer(), String.t()}
"""
def human_byte_size(number, opts \\ [])
def human_byte_size(nil, opts), do: human_byte_size(0, opts)
def human_byte_size(number, opts) do
precision = Keyword.get(opts, :precision, 2)
suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
base = 1024
Enum.reduce_while(suffixes, {number / 1.0, "B"}, fn suffix, {value, _} ->
if value < base do
{:halt, {Float.round(value, precision), suffix}}
else
{:cont, {value / base, suffix}}
end
end)
end
end end
+30 -4
View File
@@ -10,7 +10,7 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date, :uploaded_at,
:duration_seconds :duration_seconds
] ]
@@ -21,7 +21,7 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date, :uploaded_at,
:duration_seconds :duration_seconds
] ]
@@ -46,6 +46,20 @@ defmodule Pinchflat.YtDlp.Media do
end end
end end
@doc """
Downloads a thumbnail for a single piece of media. Usually used for
downloading thumbnails for internal use
Returns {:ok, ""} | {:error, any, ...}.
"""
def download_thumbnail(url, command_opts \\ []) do
opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts
# NOTE: it doesn't seem like this command actually returns anything in `after_move` since
# we aren't downloading the main media file
backend_runner().run(url, opts, "after_move:%()j")
end
@doc """ @doc """
Returns a map representing the media at the given URL. Returns a map representing the media at the given URL.
@@ -72,7 +86,7 @@ defmodule Pinchflat.YtDlp.Media do
Returns the output template for yt-dlp's indexing command. Returns the output template for yt-dlp's indexing command.
""" """
def indexing_output_template do def indexing_output_template do
"%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date})j" "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j"
end end
@doc """ @doc """
@@ -90,7 +104,7 @@ defmodule Pinchflat.YtDlp.Media do
livestream: !!response["was_live"], livestream: !!response["was_live"],
duration_seconds: response["duration"] && round(response["duration"]), duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response), short_form_content: response["webpage_url"] && short_form_content?(response),
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"]) uploaded_at: response["upload_date"] && parse_uploaded_at(response)
} }
end end
@@ -110,6 +124,18 @@ defmodule Pinchflat.YtDlp.Media do
end end
end end
defp parse_uploaded_at(%{"timestamp" => ts} = response) when is_number(ts) do
case DateTime.from_unix(ts) do
{:ok, datetime} -> datetime
_ -> MetadataFileHelpers.parse_upload_date(response["upload_date"])
end
end
# This field is needed before inserting into the database, but absence
# of this field should fail at insert-time rather than here
defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])
defp backend_runner do defp backend_runner do
# This approach lets us mock the command for testing # This approach lets us mock the command for testing
Application.get_env(:pinchflat, :yt_dlp_runner) Application.get_env(:pinchflat, :yt_dlp_runner)
@@ -2,7 +2,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
@moduledoc false @moduledoc false
use Phoenix.Component use Phoenix.Component
alias PinchflatWeb.CoreComponents import PinchflatWeb.CoreComponents
import PinchflatWeb.CustomComponents.TextComponents
@doc """ @doc """
Renders a table component with the given rows and columns. Renders a table component with the given rows and columns.
@@ -76,11 +77,13 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
phx-click={@page_number != 1 && "page_change"} phx-click={@page_number != 1 && "page_change"}
phx-value-direction="dec" phx-value-direction="dec"
> >
<CoreComponents.icon name="hero-chevron-left" /> <.icon name="hero-chevron-left" />
</span> </span>
</li> </li>
<li> <li>
<span class="mx-2">Page <%= @page_number %> of <%= @total_pages %></span> <span class="mx-2">
Page <.localized_number number={@page_number} /> of <.localized_number number={@total_pages} />
</span>
</li> </li>
<li> <li>
<span <span
@@ -92,7 +95,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
phx-click={@page_number != @total_pages && "page_change"} phx-click={@page_number != @total_pages && "page_change"}
phx-value-direction="inc" phx-value-direction="inc"
> >
<CoreComponents.icon name="hero-chevron-right" /> <.icon name="hero-chevron-right" />
</span> </span>
</li> </li>
</ul> </ul>
@@ -94,4 +94,28 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time> <time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
""" """
end end
@doc """
Renders a localized number using the Intl.NumberFormat API, falling back to the raw number if needed
"""
attr :number, :any, required: true
def localized_number(assigns) do
~H"""
<span x-data x-text={"Intl.NumberFormat().format(#{@number})"}><%= @number %></span>
"""
end
@doc """
Renders a word with a suffix if the count is not 1
"""
attr :word, :string, required: true
attr :count, :integer, required: true
attr :suffix, :string, default: "s"
def pluralize(assigns) do
~H"""
<%= @word %><%= if @count == 1, do: "", else: @suffix %>
"""
end
end end
+1 -1
View File
@@ -68,7 +68,7 @@ defmodule PinchflatWeb.Layouts do
</span> </span>
</span> </span>
<ul x-bind:class="selected ? 'block' :'hidden'"> <ul x-cloak x-show="selected">
<li :for={menu <- @submenu} class="text-bodydark2"> <li :for={menu <- @submenu} class="text-bodydark2">
<.sidebar_link icon={menu[:icon]} text={menu[:text]} href={menu[:href]} target={menu[:target]} class="pl-10" /> <.sidebar_link icon={menu[:icon]} text={menu[:text]} href={menu[:href]} target={menu[:target]} class="pl-10" />
</li> </li>
@@ -1,4 +1,4 @@
<header class="sticky top-0 z-999 flex h-20 w-full bg-white drop-shadow-1 dark:bg-boxdark dark:drop-shadow-none"> <header class="sticky top-0 z-999 flex min-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 w-2/6"> <div class="flex items-center gap-2 sm:gap-4 lg:hidden w-2/6">
<section class="pr-1"> <section class="pr-1">
@@ -22,7 +22,7 @@
type="text" type="text"
name="q" name="q"
value={@params["q"]} value={@params["q"]}
placeholder="Type to search..." placeholder="Search all media..."
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none" class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
/> />
</form> </form>
@@ -19,6 +19,7 @@
onboarding: #{Settings.get!(:onboarding)} onboarding: #{Settings.get!(:onboarding)}
}"} }"}
class="dark text-bodydark bg-boxdark-2" class="dark text-bodydark bg-boxdark-2"
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
> >
<%= @inner_content %> <%= @inner_content %>
@@ -85,6 +85,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|> put_resp_header("accept-ranges", "bytes") |> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}") |> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|> put_resp_header("content-length", to_string(length)) |> put_resp_header("content-length", to_string(length))
|> put_resp_header("content-disposition", "inline; filename=\"#{media_item.title}\"")
|> send_file(206, media_item.media_filepath, start_pos, length) |> send_file(206, media_item.media_filepath, start_pos, length)
{:error, :invalid_range} -> {:error, :invalid_range} ->
@@ -92,8 +93,10 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
conn conn
|> put_resp_content_type(mime_type) |> put_resp_content_type(mime_type)
|> put_resp_header("content-length", to_string(file_size))
|> put_resp_header("accept-ranges", "bytes") |> put_resp_header("accept-ranges", "bytes")
|> put_resp_header("content-range", "bytes 0-#{file_size - 1}/#{file_size}")
|> put_resp_header("content-length", to_string(file_size))
|> put_resp_header("content-disposition", "inline; filename=\"#{media_item.title}\"")
|> send_file(200, media_item.media_filepath) |> send_file(200, media_item.media_filepath)
end end
else else
@@ -31,11 +31,19 @@
<.media_preview media_item={@media_item} /> <.media_preview media_item={@media_item} />
</div> </div>
<aside class="mt-4 xl:mt-0"> <aside class="mt-4 xl:mt-0">
<div>Uploaded: <%= @media_item.upload_date %></div> <div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
<div :if={URI.parse(@media_item.original_url).scheme =~ "http"}> <div>
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link> <span :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
<.subtle_link href={@media_item.original_url} target="_blank">Open Original</.subtle_link>
<span class="mx-2">or</span>
</span>
<span>
<.subtle_link href={~p"/media/#{@media_item.uuid}/stream"} target="_blank">
Open Local Stream
</.subtle_link>
</span>
</div> </div>
<div class="mt-4 text-bodydark"> <div :if={@media_item.description} class="mt-4 text-bodydark">
<.break_on_newline text={@media_item.description} /> <.break_on_newline text={@media_item.description} />
</div> </div>
</aside> </aside>
@@ -183,7 +183,7 @@
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="How to handle past livestreams"
x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))" x-init="$watch('selectedPreset', p => p && ($el.value = presets[p]))"
/> />
</section> </section>
@@ -20,14 +20,14 @@ defmodule PinchflatWeb.Pages.PageController do
end end
defp render_home_page(conn) do defp render_home_page(conn) do
downloaded_media_items = where(MediaQuery.new(), ^MediaQuery.downloaded())
conn conn
|> render(:home, |> render(:home,
media_profile_count: Repo.aggregate(MediaProfile, :count, :id), media_profile_count: Repo.aggregate(MediaProfile, :count, :id),
source_count: Repo.aggregate(Source, :count, :id), source_count: Repo.aggregate(Source, :count, :id),
media_item_count: media_item_size: Repo.aggregate(downloaded_media_items, :sum, :media_size_bytes),
MediaQuery.new() media_item_count: Repo.aggregate(downloaded_media_items, :count, :id)
|> where(^MediaQuery.downloaded())
|> Repo.aggregate(:count, :id)
) )
end end
@@ -1,5 +1,23 @@
defmodule PinchflatWeb.Pages.PageHTML do defmodule PinchflatWeb.Pages.PageHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
alias Pinchflat.Utils.NumberUtils
embed_templates "page_html/*" embed_templates "page_html/*"
attr :media_filesize, :integer, required: true
def readable_media_filesize(assigns) do
{num, suffix} = NumberUtils.human_byte_size(assigns.media_filesize, precision: 2)
assigns =
Map.merge(assigns, %{
num: num,
suffix: suffix
})
~H"""
<.localized_number number={@num} /> <%= @suffix %>
"""
end
end end
@@ -22,7 +22,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
<div> <div>
<span class="mb-4 flex items-center"> <span class="mb-4 flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" /> <.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span> <span class="ml-2">
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@total_record_count} />
</span>
</span> </span>
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
@@ -32,7 +34,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_item} label="Upload Date"> <:col :let={media_item} label="Upload Date">
<%= media_item.upload_date %> <%= DateTime.to_date(media_item.uploaded_at) %>
</:col> </:col>
<:col :let={media_item} label="Indexed At"> <:col :let={media_item} label="Indexed At">
<%= format_datetime(media_item.inserted_at) %> <%= format_datetime(media_item.inserted_at) %>
@@ -1,25 +1,39 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<a href={~p"/media_profiles"} class="mt-4 flex flex-col items-center justify-center"> <a href={~p"/media_profiles"} class="flex flex-col items-center py-2">
<span class="text-md font-medium">Media Profile(s)</span> <span class="text-md font-medium">
Media <.pluralize count={@media_profile_count} word="Profile" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @media_profile_count %> <.localized_number number={@media_profile_count} />
</h4> </h4>
</a> </a>
</div> </div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<a href={~p"/sources"} class="mt-4 flex flex-col items-center justify-center"> <a href={~p"/sources"} class="flex flex-col items-center py-2">
<span class="text-md font-medium">Source(s)</span> <span class="text-md font-medium">
<.pluralize count={@source_count} word="Source" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @source_count %> <.localized_number number={@source_count} />
</h4> </h4>
</a> </a>
</div> </div>
<div class="rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark"> <div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<span class="mt-4 flex flex-col items-center justify-center"> <span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Downloaded Media</span> <span class="text-md font-medium">
<.pluralize count={@media_item_count} word="Download" />
</span>
<h4 class="text-title-md font-bold text-white"> <h4 class="text-title-md font-bold text-white">
<%= @media_item_count %> <.localized_number number={@media_item_count} />
</h4>
</span>
</div>
<div class="flex flex-col justify-center rounded-sm border px-7.5 py-6 shadow-default border-strokedark bg-boxdark">
<span class="flex flex-col items-center py-2">
<span class="text-md font-medium">Library Size</span>
<h4 class="text-title-md font-bold text-white">
<.readable_media_filesize media_filesize={@media_item_size} />
</h4> </h4>
</span> </span>
</div> </div>
@@ -10,19 +10,13 @@
<%= 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">
<%= result.title %> <.subtle_link href={~p"/sources/#{result.source_id}/media/#{result.id}"}>
<%= StringUtils.truncate(result.title, 35) %>
</.subtle_link>
</: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} />
</:col> </:col>
<:col :let={result} label="" class="flex place-content-evenly">
<.link
href={~p"/sources/#{result.source_id}/media/#{result.id}"}
class="hover:text-secondary duration-200 ease-in-out mx-0.5"
>
<.icon name="hero-eye" />
</.link>
</:col>
</.table> </.table>
<% else %> <% else %>
<p class="font-bold text-lg text-center text-black dark:text-white">No results found</p> <p class="font-bold text-lg text-center text-black dark:text-white">No results found</p>
@@ -1,8 +1,6 @@
defmodule PinchflatWeb.Settings.SettingHTML do defmodule PinchflatWeb.Settings.SettingHTML do
use PinchflatWeb, :html use PinchflatWeb, :html
alias Pinchflat.Downloading.CodecParser
embed_templates "setting_html/*" embed_templates "setting_html/*"
@doc """ @doc """
@@ -16,18 +14,27 @@ defmodule PinchflatWeb.Settings.SettingHTML do
def apprise_server_help do def apprise_server_help do
url = "https://github.com/caronc/apprise/wiki/URLBasics" url = "https://github.com/caronc/apprise/wiki/URLBasics"
classes = "underline decoration-bodydark decoration-1 hover:decoration-white"
~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{classes}" target="_blank">Apprise docs</a> for more information) ~s(Server endpoint for Apprise notifications when new media is found. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">Apprise docs</a> for more information)
end
def youtube_api_help do
url = "https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key"
~s(API key for YouTube Data API v3. Greatly improves the accuracy of Fast Indexing. See <a href="#{url}" class="#{help_link_classes()}" target="_blank">here</a> for details on generating an API key)
end end
def diagnostic_info_string do def diagnostic_info_string do
""" """
App Version: #{Application.spec(:pinchflat)[:vsn]} - App Version: #{Application.spec(:pinchflat)[:vsn]}
yt-dlp Version: #{Settings.get!(:yt_dlp_version)} - yt-dlp Version: #{Settings.get!(:yt_dlp_version)}
Apprise Version: #{Settings.get!(:apprise_version)} - Apprise Version: #{Settings.get!(:apprise_version)}
System Architecture: #{to_string(:erlang.system_info(:system_architecture))} - System Architecture: #{to_string(:erlang.system_info(:system_architecture))}
Timezone: #{Application.get_env(:pinchflat, :timezone)} - Timezone: #{Application.get_env(:pinchflat, :timezone)}
""" """
end end
defp help_link_classes do
"underline decoration-bodydark decoration-1 hover:decoration-white"
end
end end
@@ -1,17 +1,17 @@
<aside> <aside>
<h2 class="text-xl font-bold mb-2">Codec Preferences</h2> <h2 class="text-xl font-bold mb-2">Codec Preference</h2>
<section class="ml-2 md:ml-4 mb-2 max-w-prose"> <section class="ml-2 md:ml-4 mb-2 max-w-prose">
<section> <p>
Available video codecs: Some users may want to specify the audio codec that <code class="text-sm">yt-dlp</code>
<ul class="list-disc ml-8"> searches for when downloading content. For these users, you can specify one audio and video
<li :for={{codec, _} <- CodecParser.video_codec_map()}><%= codec %></li> codec to prefer above all others.
</ul> </p>
</section> <p class="mt-4">
<section class="mt-4"> NOTE: this is a <em>soft</em>
Available audio codecs: preference. If the codec you specify is not available, <code class="text-sm">yt-dlp</code>
<ul class="list-disc ml-8"> will download the next best available codec. This is expected and intended behavior.
<li :for={{codec, _} <- CodecParser.audio_codec_map()}><%= codec %></li> <.inline_link href="https://github.com/yt-dlp/yt-dlp#sorting-formats">See here</.inline_link>
</ul> for more information and a list of available codecs.
</section> </p>
</section> </section>
</aside> </aside>
@@ -15,7 +15,7 @@
Notification Settings Notification Settings
</h3> </h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode"> <span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span> Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
</span> </span>
</section> </section>
@@ -26,7 +26,25 @@
) %> ) %>
</section> </section>
<section class="mt-10" x-show="advancedMode"> <section class="mt-8">
<section>
<h3 class="text-2xl text-black dark:text-white">
Indexing Settings
</h3>
<.input
field={f[:youtube_api_key]}
placeholder="ABC123"
type="text"
label="YouTube API Key"
help={youtube_api_help()}
html_help={true}
inputclass="font-mono text-sm mr-4"
/>
</section>
</section>
<section class="mt-8" x-show="advancedMode">
<section> <section>
<h3 class="text-2xl text-black dark:text-white"> <h3 class="text-2xl text-black dark:text-white">
Codec Options Codec Options
@@ -37,24 +55,20 @@
</p> </p>
<.input <.input
id="video_codec_preference_string" field={f[:video_codec_preference]}
name="setting[video_codec_preference_string]" placeholder="avc"
value={Enum.join(f[:video_codec_preference].value, ">")}
placeholder="avc>vp9>av01"
type="text" type="text"
label="Video Codec Preference" label="Video Codec Preference"
help="Order of preference for video codecs. Separate with >. Will be remuxed into an MP4 container. See below for available codecs" help="Video codec preference. Will be remuxed into an MP4 container. See below for more details"
inputclass="font-mono text-sm mr-4" inputclass="font-mono text-sm mr-4"
/> />
<.input <.input
id="audio_codec_preference_string" field={f[:audio_codec_preference]}
name="setting[audio_codec_preference_string]" placeholder="m4a"
value={Enum.join(f[:audio_codec_preference].value, ">")}
placeholder="mp4a>opus>aac"
type="text" type="text"
label="Audio Codec Preference" label="Audio Codec Preference"
help="Order of preference for audio codecs. Separate with >. See below for available codecs" help="Audio codec preference. See below for more details"
inputclass="font-mono text-sm mr-4" inputclass="font-mono text-sm mr-4"
/> />
</section> </section>
@@ -15,6 +15,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
def friendly_index_frequencies do def friendly_index_frequencies do
[ [
{"Only once when first created", -1}, {"Only once when first created", -1},
{"30 minutes", 30},
{"1 Hour", 60}, {"1 Hour", 60},
{"3 Hours", 3 * 60}, {"3 Hours", 3 * 60},
{"6 Hours", 6 * 60}, {"6 Hours", 6 * 60},
@@ -1,21 +1,63 @@
<aside> <aside>
<h2 class="text-xl font-bold mb-2">What is fast indexing (experimental)?</h2> <h2 class="text-2xl font-bold mb-2">What is fast indexing?</h2>
<section class="ml-2 md:ml-4 mb-4 max-w-prose"> <section class="ml-2 md:ml-4 mb-4 max-w-prose">
<p> <p>
Indexing is the act of scanning a channel or playlist (aka: source) for new media. Indexing is the act of scanning a channel or playlist (aka: source) for new media.
</p> </p>
<p class="mt-2"> <p class="mt-2">
Normal indexing uses <code class="text-sm">yt-dlp</code> Normal indexing uses <code class="text-sm">yt-dlp</code>
to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source with a low index frequency will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time, so this can impact your other source's ability to index. to scan the entire source on your specified frequency, but it's very slow for large sources. This is the most accurate way to find uploaded media with the tradeoff being that pairing a large source that's indexed frequently will result in you spending most of your time indexing. Only so many indexing operations can be running at the same time so this can impact your other source's ability to index.
</p> </p>
<p class="mt-2"> <p class="mt-2">
Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses an RSS feed to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes. It works well for most channels or playlists but it's not perfect. Fast indexing takes a different approach. It still does an initial scan the slow way but after that it uses a secondary mechanism (either RSS or YouTube's API) to frequently check for new videos. This has the potential to be hundreds of times faster, but it can miss videos if the uploader un-privates an old video or uploads dozens of videos in the space of a few minutes.
</p>
<p class="mt-2">
RSS is used by default but you should enable the YouTube API if you want the best version of fast indexing. This isn't needed for most users but it provides the fastest and most reliable media updates.
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
Here is some documentation
</.inline_link>
on how to get your API key which you can add in the
<.inline_link href={~p"/settings"}>
settings
</.inline_link>
page.
</p> </p>
<p class="mt-2"> <p class="mt-2">
To make up for this limitation, a normal index is still run monthly to catch any videos that were missed by fast indexing. Fast indexing overrides the normal index frequency. To make up for this limitation, a normal index is still run monthly to catch any videos that were missed by fast indexing. Fast indexing overrides the normal index frequency.
</p> </p>
<p class="mt-2"> <p class="mt-4">
Fast indexing is experimental so please report any issues on GitHub. It's only recommended for sources with over 200-ish videos and that upload frequently. Not recommended for small or inactive sources. <h4 class="font-bold text-xl">TL;DR</h4>
<strong class="mt-2 inline-block">In general:</strong>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>
Uses RSS by default which is fine for most users
</li>
<li>
<.inline_link href="https://github.com/kieraneglin/pinchflat/wiki/Generating-a-YouTube-API-key">
Create a YouTube API key
</.inline_link>
and add it in your
<.inline_link href={~p"/settings"}>
settings
</.inline_link>
for the fastest possible media updates
</li>
</ul>
<strong class="mt-2 inline-block">Fast indexing is great if any of these apply:</strong>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>The source is large channel and uploads frequently</li>
<li>You want to download a source's new content as soon as possible</li>
</ul>
<strong class="mt-2 inline-block">Consider <em>not</em> using fast indexing if any of these apply:</strong>
<ul class="list-disc list-inside ml-2 md:ml-5">
<li>The source is a playlist</li>
<li>The source has under 200 videos</li>
<li>The source rarely uploads</li>
<li>You don't mind if it takes longer for new content to be picked up</li>
</ul>
</p> </p>
</section> </section>
</aside> </aside>
@@ -19,11 +19,12 @@
</.subtle_link> </.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="Pending"><%= source.pending_count %></:col> <:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
<:col :let={source} label="Downloaded"><%= source.downloaded_count %></:col> <:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
<:col :let={source} label="Retention"> <:col :let={source} label="Retention">
<%= if source.retention_period_days && source.retention_period_days > 0 do %> <%= if source.retention_period_days && source.retention_period_days > 0 do %>
<%= source.retention_period_days %> day(s) <.localized_number number={source.retention_period_days} />
<.pluralize count={source.retention_period_days} word="day" />
<% else %> <% else %>
<span class="text-lg">∞</span> <span class="text-lg">∞</span>
<% end %> <% end %>
@@ -8,7 +8,7 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
@limit 10 @limit 10
def render(%{records: []} = assigns) do def render(%{total_record_count: 0} = assigns) do
~H""" ~H"""
<div class="mb-4 flex items-center"> <div class="mb-4 flex items-center">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" /> <.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
@@ -20,16 +20,43 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div> <div>
<span class="mb-4 flex items-center"> <header class="flex justify-between items-center mb-4">
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" /> <span class="flex items-center">
<span class="ml-2">Showing <%= length(@records) %> of <%= @total_record_count %></span> <.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" tooltip="Refresh" />
</span> <span class="ml-2">
Showing <.localized_number number={length(@records)} /> of <.localized_number number={@filtered_record_count} />
</span>
</span>
<div class="bg-meta-4 rounded-md">
<div class="relative">
<span class="absolute left-2 top-1/2 -translate-y-1/2 flex">
<.icon name="hero-magnifying-glass" />
</span>
<form phx-change="search_term" phx-submit="search_term">
<input
type="text"
name="q"
value={@search_term}
placeholder="Search in table..."
class="w-full bg-transparent pl-9 pr-4 border-0 focus:ring-0 focus:outline-none"
phx-debounce="200"
/>
</form>
</div>
</div>
</header>
<.table rows={@records} table_class="text-white"> <.table rows={@records} table_class="text-white">
<:col :let={media_item} label="Title"> <:col :let={media_item} label="Title">
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}> <.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
<%= StringUtils.truncate(media_item.title, 50) %> <%= StringUtils.truncate(media_item.title, 50) %>
</.subtle_link> </.subtle_link>
</:col> </:col>
<:col :let={media_item} :if={@media_state == "other"} label="Manually Ignored?">
<.icon name={if media_item.prevent_download, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={media_item} label="Upload Date">
<%= DateTime.to_date(media_item.uploaded_at) %>
</:col>
<:col :let={media_item} label="" class="flex justify-end"> <:col :let={media_item} label="" class="flex justify-end">
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" /> <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
</:col> </:col>
@@ -42,36 +69,99 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
end end
def mount(_params, session, socket) do def mount(_params, session, socket) do
PinchflatWeb.Endpoint.subscribe("media_table")
page = 1 page = 1
media_state = session["media_state"] media_state = session["media_state"]
source = Sources.get_source!(session["source_id"]) source = Sources.get_source!(session["source_id"])
base_query = generate_base_query(source, media_state) base_query = generate_base_query(source, media_state)
pagination_attrs = fetch_pagination_attributes(base_query, page) pagination_attrs = fetch_pagination_attributes(base_query, page, nil)
{:ok, assign(socket, Map.merge(pagination_attrs, %{base_query: base_query, source: source}))} new_assigns =
Map.merge(
pagination_attrs,
%{
base_query: base_query,
source: source,
media_state: media_state
}
)
{:ok, assign(socket, new_assigns)}
end end
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
direction = if direction == "inc", do: 1, else: -1 direction = if direction == "inc", do: 1, else: -1
new_page = assigns.page + direction new_page = assigns.page + direction
new_assigns = fetch_pagination_attributes(assigns.base_query, new_page) new_assigns = fetch_pagination_attributes(assigns.base_query, new_page, assigns.search_term)
{:noreply, assign(socket, new_assigns)} {:noreply, assign(socket, new_assigns)}
end end
def handle_event("reload_page", _params, %{assigns: assigns} = socket) do def handle_event("search_term", params, socket) do
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page) search_term = Map.get(params, "q", nil)
new_assigns = fetch_pagination_attributes(socket.assigns.base_query, 1, search_term)
{:noreply, assign(socket, new_assigns)} {:noreply, assign(socket, new_assigns)}
end end
defp fetch_pagination_attributes(base_query, page) do # This, along with the handle_info below, is a pattern to reload _all_
# tables on page rather than just the one that triggered the reload.
def handle_event("reload_page", _params, socket) do
PinchflatWeb.Endpoint.broadcast("media_table", "reload", nil)
{:noreply, socket}
end
def handle_info(%{topic: "media_table", event: "reload"}, %{assigns: assigns} = socket) do
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page, assigns.search_term)
{:noreply, assign(socket, new_assigns)}
end
defp fetch_pagination_attributes(base_query, page, ""), do: fetch_pagination_attributes(base_query, page, nil)
defp fetch_pagination_attributes(base_query, page, nil) do
total_record_count = Repo.aggregate(base_query, :count, :id) total_record_count = Repo.aggregate(base_query, :count, :id)
total_pages = max(ceil(total_record_count / @limit), 1) total_pages = max(ceil(total_record_count / @limit), 1)
page = NumberUtils.clamp(page, 1, total_pages) page = NumberUtils.clamp(page, 1, total_pages)
records = fetch_records(base_query, page)
%{page: page, total_pages: total_pages, records: records, total_record_count: total_record_count} records =
fetch_records(base_query, page)
|> order_by(desc: :uploaded_at)
|> Repo.all()
%{
page: page,
total_pages: total_pages,
records: records,
search_term: nil,
total_record_count: total_record_count,
filtered_record_count: total_record_count
}
end
defp fetch_pagination_attributes(base_query, page, search_term) do
filtered_base_query = filtered_base_query(base_query, search_term)
total_record_count = Repo.aggregate(base_query, :count, :id)
filtered_record_count = Repo.aggregate(filtered_base_query, :count, :id)
total_pages = max(ceil(filtered_record_count / @limit), 1)
page = NumberUtils.clamp(page, 1, total_pages)
records =
fetch_records(filtered_base_query, page)
|> order_by(desc: fragment("rank"), desc: :uploaded_at)
|> Repo.all()
%{
page: page,
total_pages: total_pages,
records: records,
search_term: search_term,
total_record_count: total_record_count,
filtered_record_count: filtered_record_count
}
end end
defp fetch_records(base_query, page) do defp fetch_records(base_query, page) do
@@ -80,19 +170,41 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
base_query base_query
|> limit(^@limit) |> limit(^@limit)
|> offset(^offset) |> offset(^offset)
|> Repo.all()
end end
defp generate_base_query(source, "pending") do defp generate_base_query(source, "pending") do
MediaQuery.new() MediaQuery.new()
|> select(^select_fields())
|> MediaQuery.require_assoc(:media_profile) |> MediaQuery.require_assoc(:media_profile)
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending())) |> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|> order_by(desc: :id)
end end
defp generate_base_query(source, "downloaded") do defp generate_base_query(source, "downloaded") do
MediaQuery.new() MediaQuery.new()
|> select(^select_fields())
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded())) |> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|> order_by(desc: :id) end
defp generate_base_query(source, "other") do
MediaQuery.new()
|> select(^select_fields())
|> MediaQuery.require_assoc(:media_profile)
|> where(
^dynamic(
^MediaQuery.for_source(source) and
(not (^MediaQuery.downloaded()) and not (^MediaQuery.pending()))
)
)
end
defp filtered_base_query(base_query, search_term) do
base_query
|> MediaQuery.require_assoc(:media_items_search_index)
|> where(^MediaQuery.matches_search_term(search_term))
end
# Selecting only what we need GREATLY speeds up queries on large tables
defp select_fields do
[:id, :title, :uploaded_at, :prevent_download]
end end
end end
@@ -36,21 +36,28 @@
<.list_items_from_map map={Map.from_struct(@source)} /> <.list_items_from_map map={Map.from_struct(@source)} />
</div> </div>
</:tab> </:tab>
<:tab title="Pending Media" id="pending"> <:tab title="Pending" id="pending">
<%= live_render( <%= live_render(
@conn, @conn,
Pinchflat.Sources.MediaItemTableLive, Pinchflat.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "pending"} session: %{"source_id" => @source.id, "media_state" => "pending"}
) %> ) %>
</:tab> </:tab>
<:tab title="Downloaded Media" id="downloaded"> <:tab title="Downloaded" id="downloaded">
<%= live_render( <%= live_render(
@conn, @conn,
Pinchflat.Sources.MediaItemTableLive, Pinchflat.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "downloaded"} session: %{"source_id" => @source.id, "media_state" => "downloaded"}
) %> ) %>
</:tab> </:tab>
<:tab title="Pending Tasks" id="tasks"> <:tab title="Other" id="other">
<%= live_render(
@conn,
Pinchflat.Sources.MediaItemTableLive,
session: %{"source_id" => @source.id, "media_state" => "other"}
) %>
</:tab>
<:tab title="Tasks" id="tasks">
<%= if match?([_|_], @pending_tasks) do %> <%= if match?([_|_], @pending_tasks) do %>
<.table rows={@pending_tasks} table_class="text-black dark:text-white"> <.table rows={@pending_tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker"> <:col :let={task} label="Worker">
@@ -15,7 +15,7 @@
General Options General Options
</h3> </h3>
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode"> <span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Basic'"></span> Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
</span> </span>
</section> </section>
@@ -49,7 +49,7 @@
label="Index Frequency" label="Index Frequency"
x-bind:disabled="fastIndexingEnabled == true" x-bind:disabled="fastIndexingEnabled == true"
x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))" x-init="$watch('fastIndexingEnabled', v => v && ($el.value = 30 * 24 * 60))"
help="Indexing is the process of checking for media to download. Sets the time between one index of this source finishing and the next one starting" help="Indexing is the process of checking for media to download. For best results, set this to the longest delay you can tolerate for this source"
/> />
<div phx-click={show_modal("upgrade-modal")}> <div phx-click={show_modal("upgrade-modal")}>
@@ -58,7 +58,7 @@
type="toggle" type="toggle"
label="Use Fast Indexing" label="Use Fast Indexing"
label_suffix="(pro)" label_suffix="(pro)"
help="Experimental. Overrides 'Index Frequency'. Recommended for large channels that upload frequently. Does not work with private playlists. See below for more info" help="Not recommended for playlists. Overrides 'Index Frequency'. See below for more details (seriously, there's a TL;DR that's worth reading)"
x-init=" x-init="
// `enabled` is the data attribute that the toggle uses internally // `enabled` is the data attribute that the toggle uses internally
fastIndexingEnabled = enabled fastIndexingEnabled = enabled
@@ -95,7 +95,8 @@
type="number" type="number"
label="Retention Period (days)" label="Retention Period (days)"
min="0" min="0"
help="Days between when media is *downloaded* and when it's deleted. Leave blank to keep media indefinitely" help="Days between when media is <em>downloaded</em> and when it's deleted. Leave blank to keep media indefinitely"
html_help={true}
/> />
<section x-show="advancedMode"> <section x-show="advancedMode">
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.5.22", version: "2024.6.10",
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,
+1 -1
View File
@@ -2,7 +2,7 @@
"description": "Prettier is used for linting of all files so this package has to live in the root of the project. Use the other package.json files for dependencies. Also, look into making this global or something to remove the need for this file.", "description": "Prettier is used for linting of all files so this package has to live in the root of the project. Use the other package.json files for dependencies. Also, look into making this global or something to remove the need for this file.",
"devDependencies": { "devDependencies": {
"prettier": "3.2.4", "prettier": "3.2.4",
"sqleton": "https://github.com/kieraneglin/sqleton#ke/add-index-support" "sqleton": "^2.2.0"
}, },
"scripts": { "scripts": {
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db" "create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 468 KiB

@@ -0,0 +1,60 @@
defmodule Pinchflat.Repo.Migrations.ChangeMediaItemsSearchIndexTokenizer do
use Ecto.Migration
def up do
# These all need to run as part of separate `execute` blocks. Do NOT ask me why.
execute "DROP TRIGGER IF EXISTS media_items_search_index_insert;"
execute "DROP TRIGGER IF EXISTS media_items_search_index_update;"
execute "DROP TRIGGER IF EXISTS media_items_search_index_delete;"
execute "DROP TABLE IF EXISTS media_items_search_index;"
execute """
CREATE VIRTUAL TABLE media_items_search_index USING fts5(
title,
description,
tokenize=trigram
);
"""
execute """
CREATE TRIGGER media_items_search_index_insert AFTER INSERT ON media_items BEGIN
INSERT INTO media_items_search_index(
rowid,
title,
description
)
VALUES(
new.id,
new.title,
new.description
);
END;
"""
execute """
CREATE TRIGGER media_items_search_index_update AFTER UPDATE ON media_items BEGIN
UPDATE media_items_search_index SET
title = new.title,
description = new.description
WHERE
rowid = old.id;
END;
"""
execute """
CREATE TRIGGER media_items_search_index_delete AFTER DELETE ON media_items BEGIN
DELETE FROM media_items_search_index WHERE rowid = old.id;
END;
"""
# Fully re-index the media_items table
execute """
INSERT INTO media_items_search_index(rowid, title, description)
SELECT id, title, description FROM media_items;
"""
end
def down do
execute "DROP TABLE media_items_search_index;"
end
end
@@ -0,0 +1,21 @@
defmodule Pinchflat.Repo.Migrations.RenameUploadDateToUploadedAt do
use Ecto.Migration
def up do
rename table(:media_items), :upload_date, to: :uploaded_at
execute """
UPDATE media_items
SET uploaded_at = uploaded_at || 'T00:00:00'
"""
end
def down do
rename table(:media_items), :uploaded_at, to: :upload_date
execute """
UPDATE media_items
SET upload_date = DATE(upload_date)
"""
end
end
@@ -0,0 +1,8 @@
defmodule Pinchflat.Repo.Migrations.ModifyUploadDateIndex do
use Ecto.Migration
def change do
drop index("media_items", [:upload_date])
create index("media_items", [:uploaded_at])
end
end
@@ -0,0 +1,19 @@
defmodule Pinchflat.Repo.Migrations.AddIndexesForLargeCollections do
use Ecto.Migration
def change do
create index(
"media_items",
[
:source_id,
:media_filepath,
:uploaded_at,
:prevent_download,
:livestream,
:short_form_content,
:title
],
name: "media_items_pending_and_downloaded_index"
)
end
end
@@ -0,0 +1,15 @@
defmodule Pinchflat.Repo.Migrations.RefactorCodecPreferences do
use Ecto.Migration
def change do
alter table(:settings) do
remove :video_codec_preference, {:array, :string}, default: []
remove :audio_codec_preference, {:array, :string}, default: []
end
alter table(:settings) do
add :video_codec_preference, :string, default: "avc", null: false
add :audio_codec_preference, :string, default: "m4a", null: false
end
end
end
@@ -0,0 +1,9 @@
defmodule Pinchflat.Repo.Migrations.AddYoutubeApiKeySetting do
use Ecto.Migration
def change do
alter table(:settings) do
add :youtube_api_key, :string
end
end
end
@@ -1,70 +0,0 @@
defmodule Pinchflat.Downloading.CodecParserTest do
use Pinchflat.DataCase
alias Pinchflat.Settings
alias Pinchflat.Downloading.CodecParser
describe "generate_vcodec_string_from_settings/1" do
test "returns a default vcodec string when setting isn't set" do
Settings.set(video_codec_preference: [])
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
end
test "generates a vcodec string" do
Settings.set(video_codec_preference: ["av01"])
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string_from_settings()
end
end
describe "generate_acodec_string_from_settings/1" do
test "returns a default acodec string when setting isn't set" do
Settings.set(audio_codec_preference: [])
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
end
test "generates an acodec string" do
Settings.set(audio_codec_preference: ["mp3"])
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string_from_settings()
end
end
describe "generate_vcodec_string/1" do
test "returns a default vcodec string when nil" do
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string(nil)
end
test "returns a default vcodec string when empty" do
assert "bestvideo[vcodec~='^avc']/bestvideo" == CodecParser.generate_vcodec_string([])
end
test "generates a vcodec string" do
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01"])
end
test "ignores options that don't exist" do
assert "bestvideo[vcodec~='^av01']/bestvideo" == CodecParser.generate_vcodec_string(["av01", "foo"])
end
end
describe "generate_acodec_string/1" do
test "returns a default acodec string when nil" do
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string(nil)
end
test "returns a default acodec string when empty" do
assert "bestaudio[acodec~='^mp4a']/bestaudio" == CodecParser.generate_acodec_string([])
end
test "generates an acodec string" do
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3"])
end
test "ignores options that don't exist" do
assert "bestaudio[acodec~='^mp3']/bestaudio" == CodecParser.generate_acodec_string(["mp3", "foo"])
end
end
end
@@ -256,10 +256,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile) media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:format_sort, "res:#{resolution}"} in res
assert {:format, "((bestvideo[vcodec~='^avc']/bestvideo)+(bestaudio[acodec~='^mp4a']/bestaudio))/best"} in res
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
assert {:remux_video, "mp4"} in res assert {:remux_video, "mp4"} in res
end) end)
end end
@@ -270,20 +268,20 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert :extract_audio in res assert :extract_audio in res
assert {:format, "bestaudio[acodec~='^mp4a']/bestaudio/best"} in res assert {:format_sort, "+acodec:m4a"} in res
refute {:remux_video, "mp4"} in res refute {:remux_video, "mp4"} in res
end end
test "includes custom quality options if specified", %{media_item: media_item} do test "includes custom quality options if specified", %{media_item: media_item} do
Settings.set(video_codec_preference: ["av01"]) Settings.set(video_codec_preference: "av01")
Settings.set(audio_codec_preference: ["aac"]) Settings.set(audio_codec_preference: "aac")
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"}) media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
assert {:ok, res} = DownloadOptionBuilder.build(media_item) assert {:ok, res} = DownloadOptionBuilder.build(media_item)
assert {:format, "((bestvideo[vcodec~='^av01']/bestvideo)+(bestaudio[acodec~='^aac']/bestaudio))/best"} in res assert {:format_sort, "res:1080,+codec:av01:aac"} in res
end end
end end
@@ -9,6 +9,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
setup do setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end) stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
@@ -14,9 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
[:metadata, source: :media_profile] [:metadata, source: :media_profile]
) )
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
{:ok, ""} stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
end)
{:ok, %{media_item: media_item}} {:ok, %{media_item: media_item}}
end end
@@ -44,7 +43,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item) assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz" assert updated_media_item.metadata.metadata_filepath =~ "media_items/#{media_item.id}/metadata.json.gz"
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg" assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg"
end end
test "non-recoverable errors are passed through", %{media_item: media_item} do test "non-recoverable errors are passed through", %{media_item: media_item} do
@@ -16,7 +16,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
media_item = media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(6, :days), uploaded_at: now_minus(6, :days),
media_downloaded_at: now_minus(5, :days) media_downloaded_at: now_minus(5, :days)
}) })
@@ -32,7 +32,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(6, :days), uploaded_at: now_minus(6, :days),
media_downloaded_at: now_minus(1, :day) media_downloaded_at: now_minus(1, :day)
}) })
@@ -6,19 +6,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Settings
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.FastIndexingHelpers alias Pinchflat.FastIndexing.FastIndexingHelpers
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, media_attributes_return_fixture()}
end)
{:ok, [source: source_fixture()]}
end
describe "kickoff_download_tasks_from_youtube_rss_feed/1" do describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
setup do
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, media_attributes_return_fixture()}
end)
{:ok, [source: source_fixture()]}
end
test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do test "enqueues a new worker for each new media_id in the source's RSS feed", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end) expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
@@ -107,4 +108,49 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source) assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end end
end end
describe "kickoff_download_tasks_from_youtube_rss_feed/1 when testing backends" do
test "uses the YouTube API if it is enabled", %{source: source} do
expect(HTTPClientMock, :get, fn url, _headers ->
assert url =~ "https://youtube.googleapis.com/youtube/v3/playlistItems"
{:ok, "{}"}
end)
Settings.set(youtube_api_key: "test_key")
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
test "the YouTube API creates records as expected", %{source: source} do
expect(HTTPClientMock, :get, fn _url, _headers ->
{:ok, ~s({ "items": [ {"contentDetails": {"videoId": "test_1"}} ] })}
end)
Settings.set(youtube_api_key: "test_key")
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
test "RSS is used as a backup if the API fails", %{source: source} do
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, ""} end)
expect(HTTPClientMock, :get, fn _url -> {:ok, "<yt:videoId>test_1</yt:videoId>"} end)
Settings.set(youtube_api_key: "test_key")
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
test "RSS is used if the API is not enabled", %{source: source} do
expect(HTTPClientMock, :get, fn url ->
assert url =~ "https://www.youtube.com/feeds/videos.xml"
{:ok, "<yt:videoId>test_1</yt:videoId>"}
end)
Settings.set(youtube_api_key: nil)
assert [%MediaItem{}] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
end
end
end end
@@ -0,0 +1,85 @@
defmodule Pinchflat.FastIndexing.YoutubeApiTest do
use Pinchflat.DataCase
import Pinchflat.SourcesFixtures
alias Pinchflat.Settings
alias Pinchflat.FastIndexing.YoutubeApi
describe "enabled?/0" do
test "returns true if the user has set a YouTube API key" do
Settings.set(youtube_api_key: "test_key")
assert YoutubeApi.enabled?()
end
test "returns false if the user has not set an API key" do
Settings.set(youtube_api_key: nil)
refute YoutubeApi.enabled?()
end
end
describe "get_recent_media_ids/1" do
setup do
source = source_fixture()
Settings.set(youtube_api_key: "test_key")
{:ok, source: source}
end
test "calls the expected URL", %{source: source} do
expect(HTTPClientMock, :get, fn url, headers ->
api_base = "https://youtube.googleapis.com/youtube/v3/playlistItems"
request_url = "#{api_base}?part=contentDetails&maxResults=50&playlistId=#{source.collection_id}&key=test_key"
assert url == request_url
assert headers == [accept: "application/json"]
{:ok, "{}"}
end)
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
end
test "replaces channel IDs with playlist IDs if needed" do
source = source_fixture(collection_id: "UC_ABC123")
expect(HTTPClientMock, :get, fn url, _headers ->
assert url =~ "playlistId=UU_ABC123&"
{:ok, "{}"}
end)
assert {:ok, _} = YoutubeApi.get_recent_media_ids(source)
end
test "returns an empty list if no media is returned", %{source: source} do
expect(HTTPClientMock, :get, fn _url, _headers -> {:ok, "{}"} end)
assert {:ok, []} = YoutubeApi.get_recent_media_ids(source)
end
test "returns media IDs if present", %{source: source} do
expect(HTTPClientMock, :get, fn _url, _headers ->
{:ok,
"""
{
"items": [
{"contentDetails": {"videoId": "test_1"}},
{"contentDetails": {"videoId": "test_2"}}
]
}
"""}
end)
assert {:ok, ["test_1", "test_2"]} = YoutubeApi.get_recent_media_ids(source)
end
test "returns an error if the HTTP request fails", %{source: source} do
expect(HTTPClientMock, :get, fn _url, _headers -> {:error, "error"} end)
assert {:error, "error"} = YoutubeApi.get_recent_media_ids(source)
end
end
end
@@ -11,7 +11,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, source: source} {:ok, source: source}
end end
describe "get_recent_media_ids_from_rss/1" do describe "enabled?/0" do
test "returns true" do
assert YoutubeRss.enabled?()
end
end
describe "get_recent_media_ids/1" do
test "calls the expected URL for channel sources" do test "calls the expected URL for channel sources" do
source = source_fixture(collection_type: :channel, collection_id: "channel_id") source = source_fixture(collection_type: :channel, collection_id: "channel_id")
@@ -21,7 +27,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
end end
test "calls the expected URL for playlist sources" do test "calls the expected URL for playlist sources" do
@@ -33,13 +39,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, ""} {:ok, ""}
end) end)
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
end end
test "returns an error if the HTTP request fails", %{source: source} do test "returns an error if the HTTP request fails", %{source: source} do
expect(HTTPClientMock, :get, fn _url -> {:error, ""} end) expect(HTTPClientMock, :get, fn _url -> {:error, ""} end)
assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:error, "Failed to fetch RSS feed"} = YoutubeRss.get_recent_media_ids(source)
end end
test "returns the media IDs from the RSS feed", %{source: source} do test "returns the media IDs from the RSS feed", %{source: source} do
@@ -47,7 +53,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_2</yt:videoId>"} {:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_2</yt:videoId>"}
end) end)
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
end end
test "strips whitespace from media IDs", %{source: source} do test "strips whitespace from media IDs", %{source: source} do
@@ -55,7 +61,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, "<yt:videoId> test_1 </yt:videoId><yt:videoId> test_2 </yt:videoId>"} {:ok, "<yt:videoId> test_1 </yt:videoId><yt:videoId> test_2 </yt:videoId>"}
end) end)
assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, ["test_1", "test_2"]} = YoutubeRss.get_recent_media_ids(source)
end end
test "removes empty media IDs", %{source: source} do test "removes empty media IDs", %{source: source} do
@@ -63,7 +69,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId></yt:videoId>"} {:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId></yt:videoId>"}
end) end)
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
end end
test "removes duplicate media IDs", %{source: source} do test "removes duplicate media IDs", %{source: source} do
@@ -71,7 +77,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
{:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_1</yt:videoId>"} {:ok, "<yt:videoId>test_1</yt:videoId><yt:videoId>test_1</yt:videoId>"}
end) end)
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source) assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
end end
end end
end end
+188 -174
View File
@@ -34,150 +34,6 @@ defmodule Pinchflat.MediaTest do
end end
end end
describe "schema when testing upload_date_index and source is a channel" do
test "upload_date_index is set to 99 if it's the only video uploaded that day" do
upload_date = Date.utc_today()
source = source_fixture(%{collection_type: :channel})
media_item = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
assert media_item.upload_date_index == 99
end
test "upload_date_index is set to 98 if it's the second video uploaded that day" do
upload_date = Date.utc_today()
source = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
media_item_two = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
assert media_item_one.upload_date_index == 99
assert media_item_two.upload_date_index == 98
end
test "upload_date_index doesn't decrement if the video is uploaded on a different day" do
today = Date.utc_today()
one_day_ago = Date.add(today, -1)
source = source_fixture(%{collection_type: :channel})
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
assert media_item_new.upload_date_index == 99
assert media_item_old.upload_date_index == 99
end
test "recomputes upload_date_index if an upload_date is changed...somehow" do
today = Date.utc_today()
one_day_ago = Date.add(today, -1)
source = source_fixture(%{collection_type: :channel})
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{upload_date: today})
assert media_item_new.upload_date_index == 99
assert updated_media_item.upload_date_index == 98
end
test "upload_date_index doesn't decrement if the video is for a different source" do
today = Date.utc_today()
source_one = source_fixture(%{collection_type: :channel})
source_two = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source_one.id, upload_date: today})
media_item_two = media_item_fixture(%{source_id: source_two.id, upload_date: today})
assert media_item_one.upload_date_index == 99
assert media_item_two.upload_date_index == 99
end
test "upload_date_index doesn't decrement if the a video's upload_date is updated but doesn't change" do
today = Date.utc_today()
source = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: today})
_media_item_two = media_item_fixture(%{source_id: source.id, upload_date: today})
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{upload_date: today, title: "New title"})
assert updated_media_item.upload_date_index == 99
end
end
describe "schema when testing upload_date_index and source is a playlist" do
test "upload_date_index is set to 0 if it's the only video uploaded that day" do
upload_date = Date.utc_today()
source = source_fixture(%{collection_type: :playlist})
media_item = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
assert media_item.upload_date_index == 0
end
test "upload_date_index is set to 1 if it's the second video uploaded that day" do
upload_date = Date.utc_today()
source = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
media_item_two = media_item_fixture(%{source_id: source.id, upload_date: upload_date})
assert media_item_one.upload_date_index == 0
assert media_item_two.upload_date_index == 1
end
test "upload_date_index doesn't increment if the video is uploaded on a different day" do
today = Date.utc_today()
one_day_ago = Date.add(today, -1)
source = source_fixture(%{collection_type: :playlist})
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
assert media_item_new.upload_date_index == 0
assert media_item_old.upload_date_index == 0
end
test "recomputes upload_date_index if an upload_date is changed...somehow" do
today = Date.utc_today()
one_day_ago = Date.add(today, -1)
source = source_fixture(%{collection_type: :playlist})
media_item_new = media_item_fixture(%{source_id: source.id, upload_date: today})
media_item_old = media_item_fixture(%{source_id: source.id, upload_date: one_day_ago})
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{upload_date: today})
assert media_item_new.upload_date_index == 0
assert updated_media_item.upload_date_index == 1
end
test "upload_date_index doesn't increment if the video is for a different source" do
today = Date.utc_today()
source_one = source_fixture(%{collection_type: :playlist})
source_two = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source_one.id, upload_date: today})
media_item_two = media_item_fixture(%{source_id: source_two.id, upload_date: today})
assert media_item_one.upload_date_index == 0
assert media_item_two.upload_date_index == 0
end
test "upload_date_index doesn't increment if the a video's upload_date is updated but doesn't change" do
today = Date.utc_today()
source = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source.id, upload_date: today})
_media_item_two = media_item_fixture(%{source_id: source.id, upload_date: today})
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{upload_date: today, title: "New title"})
assert updated_media_item.upload_date_index == 0
end
end
describe "list_media_items/0" do describe "list_media_items/0" do
test "it returns all media_items" do test "it returns all media_items" do
media_item = media_item_fixture() media_item = media_item_fixture()
@@ -289,7 +145,7 @@ defmodule Pinchflat.MediaTest do
media_item = media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(6, :days), uploaded_at: now_minus(6, :days),
media_downloaded_at: now_minus(5, :days) media_downloaded_at: now_minus(5, :days)
}) })
@@ -300,7 +156,7 @@ defmodule Pinchflat.MediaTest do
media_item = media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(20, :days), uploaded_at: now_minus(20, :days),
media_downloaded_at: now_minus(19, :days) media_downloaded_at: now_minus(19, :days)
}) })
@@ -311,7 +167,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(5, :days), uploaded_at: now_minus(5, :days),
media_downloaded_at: nil media_downloaded_at: nil
}) })
@@ -322,7 +178,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(5, :days), uploaded_at: now_minus(5, :days),
media_downloaded_at: now(), media_downloaded_at: now(),
prevent_download: true prevent_download: true
}) })
@@ -334,7 +190,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(5, :days), uploaded_at: now_minus(5, :days),
media_downloaded_at: now(), media_downloaded_at: now(),
culled_at: now() culled_at: now()
}) })
@@ -346,7 +202,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(3, :days), uploaded_at: now_minus(3, :days),
media_downloaded_at: now_minus(3, :days) media_downloaded_at: now_minus(3, :days)
}) })
@@ -357,7 +213,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(5, :days), uploaded_at: now_minus(5, :days),
media_downloaded_at: now(), media_downloaded_at: now(),
media_redownloaded_at: now() media_redownloaded_at: now()
}) })
@@ -365,12 +221,12 @@ defmodule Pinchflat.MediaTest do
assert Media.list_redownloadable_media_items() == [] assert Media.list_redownloadable_media_items() == []
end end
test "does not return media items that were first downloaded well after the upload_date", %{source: source} do test "does not return media items that were first downloaded well after the uploaded_at", %{source: source} do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
media_downloaded_at: now(), media_downloaded_at: now(),
upload_date: now_minus(20, :days) uploaded_at: now_minus(20, :days)
}) })
assert Media.list_redownloadable_media_items() == [] assert Media.list_redownloadable_media_items() == []
@@ -381,7 +237,7 @@ defmodule Pinchflat.MediaTest do
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
media_downloaded_at: now(), media_downloaded_at: now(),
upload_date: now_minus(2, :days) uploaded_at: now_minus(2, :days)
}) })
assert Media.list_redownloadable_media_items() == [] assert Media.list_redownloadable_media_items() == []
@@ -394,7 +250,7 @@ defmodule Pinchflat.MediaTest do
_media_item = _media_item =
media_item_fixture(%{ media_item_fixture(%{
source_id: source.id, source_id: source.id,
upload_date: now_minus(6, :days), uploaded_at: now_minus(6, :days),
media_downloaded_at: now_minus(5, :days) media_downloaded_at: now_minus(5, :days)
}) })
@@ -552,9 +408,9 @@ defmodule Pinchflat.MediaTest do
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)}) source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
_old_media_item = _old_media_item =
media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now_minus(2, :days)}) media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
new_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now()}) new_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now()})
assert Media.list_pending_media_items_for(source) == [new_media_item] assert Media.list_pending_media_items_for(source) == [new_media_item]
end end
@@ -563,9 +419,9 @@ defmodule Pinchflat.MediaTest do
source = source_fixture(%{download_cutoff_date: nil}) source = source_fixture(%{download_cutoff_date: nil})
old_media_item = old_media_item =
media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now_minus(2, :days)}) media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
new_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now()}) new_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now()})
assert Media.list_pending_media_items_for(source) == [old_media_item, new_media_item] assert Media.list_pending_media_items_for(source) == [old_media_item, new_media_item]
end end
@@ -630,21 +486,21 @@ defmodule Pinchflat.MediaTest do
test "returns true if there is a cutoff date before the media's upload date" do test "returns true if there is a cutoff date before the media's upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(2, :days)}) source = source_fixture(%{download_cutoff_date: now_minus(2, :days)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now_minus(1, :day)}) media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(1, :day)})
assert Media.pending_download?(media_item) assert Media.pending_download?(media_item)
end end
test "returns false if there is a cutoff date after the media's upload date" do test "returns false if there is a cutoff date after the media's upload date" do
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)}) source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now_minus(2, :days)}) media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(2, :days)})
refute Media.pending_download?(media_item) refute Media.pending_download?(media_item)
end end
test "returns true if there is no cutoff date" do test "returns true if there is no cutoff date" do
source = source_fixture(%{download_cutoff_date: nil}) source = source_fixture(%{download_cutoff_date: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, upload_date: now_minus(1, :day)}) media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil, uploaded_at: now_minus(1, :day)})
assert Media.pending_download?(media_item) assert Media.pending_download?(media_item)
end end
@@ -727,6 +583,17 @@ defmodule Pinchflat.MediaTest do
test "returns an empty list when the search term is nil" do test "returns an empty list when the search term is nil" do
assert [] = Media.search(nil) assert [] = Media.search(nil)
end end
test "doesn't blow up if there's an apostrophe or quotes in the search term" do
assert [] = Media.search("don't expl'ode")
assert [] = Media.search(~s(dont expl"o"de))
assert [] = Media.search(~s(dont explo"de))
end
test "doesn't blow up if there is a trailing operand" do
assert [] = Media.search("foo OR")
assert [] = Media.search("foo AND")
end
end end
describe "get_media_item!/1" do describe "get_media_item!/1" do
@@ -744,7 +611,7 @@ defmodule Pinchflat.MediaTest do
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id, source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today() uploaded_at: now()
} }
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs) assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
@@ -761,7 +628,7 @@ defmodule Pinchflat.MediaTest do
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id, source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today() uploaded_at: now()
} }
assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs) assert {:ok, %MediaItem{} = media_item} = Media.create_media_item(valid_attrs)
@@ -776,7 +643,7 @@ defmodule Pinchflat.MediaTest do
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id, source_id: source_fixture().id,
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
upload_date: Date.utc_today(), uploaded_at: now(),
uuid: "some-uuid" uuid: "some-uuid"
} }
@@ -873,14 +740,13 @@ defmodule Pinchflat.MediaTest do
end end
test "does delete the media item's metadata files" do test "does delete the media item's metadata files" do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), :metadata) media_item = Repo.preload(media_item_with_attachments(), :metadata)
update_attrs = %{ update_attrs = %{
metadata: %{ metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}), metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
thumbnail_filepath: thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
} }
} }
@@ -906,14 +772,13 @@ defmodule Pinchflat.MediaTest do
end end
test "deletes the media item's metadata files" do test "deletes the media item's metadata files" do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), :metadata) media_item = Repo.preload(media_item_with_attachments(), :metadata)
update_attrs = %{ update_attrs = %{
metadata: %{ metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}), metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
thumbnail_filepath: thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
} }
} }
@@ -993,14 +858,13 @@ defmodule Pinchflat.MediaTest do
end end
test "does not delete the media item's metadata files" do test "does not delete the media item's metadata files" do
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
media_item = Repo.preload(media_item_with_attachments(), :metadata) media_item = Repo.preload(media_item_with_attachments(), :metadata)
update_attrs = %{ update_attrs = %{
metadata: %{ metadata: %{
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}), metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
thumbnail_filepath: thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
} }
} }
@@ -1041,4 +905,154 @@ defmodule Pinchflat.MediaTest do
assert %Ecto.Changeset{} = Media.change_media_item(media_item) assert %Ecto.Changeset{} = Media.change_media_item(media_item)
end end
end end
describe "change_media_item/1 when testing upload_date_index and source is a channel" do
test "upload_date_index is set to 99 if it's the only video uploaded that day" do
source = source_fixture(%{collection_type: :channel})
media_item = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
assert media_item.upload_date_index == 99
end
test "upload_date_index is set to 98 if it's the second video uploaded that day" do
source = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
assert media_item_one.upload_date_index == 99
assert media_item_two.upload_date_index == 98
end
test "upload_date_index doesn't decrement if the video is uploaded on a different day" do
source = source_fixture(%{collection_type: :channel})
media_item_new = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_old = media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(1, :day)})
assert media_item_new.upload_date_index == 99
assert media_item_old.upload_date_index == 99
end
test "recomputes upload_date_index if an upload_date is changed...somehow" do
source = source_fixture(%{collection_type: :channel})
media_item_new = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_old = media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(1, :day)})
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{uploaded_at: now()})
assert media_item_new.upload_date_index == 99
assert updated_media_item.upload_date_index == 98
end
test "upload_date_index doesn't decrement if the video is for a different source" do
source_one = source_fixture(%{collection_type: :channel})
source_two = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source_one.id, uploaded_at: now()})
media_item_two = media_item_fixture(%{source_id: source_two.id, uploaded_at: now()})
assert media_item_one.upload_date_index == 99
assert media_item_two.upload_date_index == 99
end
test "upload_date_index doesn't decrement if the a video's upload_date is updated but doesn't change" do
source = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{uploaded_at: now(), title: "New title"})
assert updated_media_item.upload_date_index == 99
end
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
source = source_fixture(%{collection_type: :channel})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
{:ok, updated_media_item} =
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
assert updated_media_item.upload_date_index == 99
end
end
describe "change_media_item/1 when testing upload_date_index and source is a playlist" do
test "upload_date_index is set to 0 if it's the only video uploaded that day" do
source = source_fixture(%{collection_type: :playlist})
media_item = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
assert media_item.upload_date_index == 0
end
test "upload_date_index is set to 1 if it's the second video uploaded that day" do
source = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
assert media_item_one.upload_date_index == 0
assert media_item_two.upload_date_index == 1
end
test "upload_date_index doesn't increment if the video is uploaded on a different day" do
source = source_fixture(%{collection_type: :playlist})
media_item_new = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_old = media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(1, :day)})
assert media_item_new.upload_date_index == 0
assert media_item_old.upload_date_index == 0
end
test "recomputes upload_date_index if an upload_date is changed...somehow" do
source = source_fixture(%{collection_type: :playlist})
media_item_new = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
media_item_old = media_item_fixture(%{source_id: source.id, uploaded_at: now_minus(1, :day)})
{:ok, updated_media_item} = Media.update_media_item(media_item_old, %{uploaded_at: now()})
assert media_item_new.upload_date_index == 0
assert updated_media_item.upload_date_index == 1
end
test "upload_date_index doesn't increment if the video is for a different source" do
source_one = source_fixture(%{collection_type: :playlist})
source_two = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source_one.id, uploaded_at: now()})
media_item_two = media_item_fixture(%{source_id: source_two.id, uploaded_at: now()})
assert media_item_one.upload_date_index == 0
assert media_item_two.upload_date_index == 0
end
test "upload_date_index doesn't increment if the a video's upload_date is updated but doesn't change" do
source = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
{:ok, updated_media_item} = Media.update_media_item(media_item_one, %{uploaded_at: now(), title: "New title"})
assert updated_media_item.upload_date_index == 0
end
test "upload_date_index doesn't increment if the a video's upload_date is changed to the same day" do
source = source_fixture(%{collection_type: :playlist})
media_item_one = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
_media_item_two = media_item_fixture(%{source_id: source.id, uploaded_at: now()})
{:ok, updated_media_item} =
Media.update_media_item(media_item_one, %{uploaded_at: now_plus(1, :minute), title: "New title"})
assert updated_media_item.upload_date_index == 0
end
end
end end
@@ -50,92 +50,47 @@ defmodule Pinchflat.Metadata.MetadataFileHelpersTest do
end end
describe "download_and_store_thumbnail_for/2" do describe "download_and_store_thumbnail_for/2" do
setup do test "returns the filepath", %{media_item: media_item} do
# This tests that the HTTP endpoint is being called with every test stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
expect(HTTPClientMock, :get, fn _url, _headers, _opts ->
{:ok, "thumbnail data"} filepath = Helpers.download_and_store_thumbnail_for(media_item)
assert filepath =~ ~r{/media_items/#{media_item.id}/thumbnail.jpg}
end
test "calls yt-dlp with the expected options", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn url, opts, ot ->
assert url == media_item.original_url
assert ot == "after_move:%()j"
assert opts == [
:no_simulate,
:skip_download,
:write_thumbnail,
convert_thumbnail: "jpg",
output: "/tmp/test/metadata/media_items/1/thumbnail.%(ext)s"
]
{:ok, ""}
end) end)
metadata = render_parsed_metadata(:media_metadata) Helpers.download_and_store_thumbnail_for(media_item)
{:ok, %{metadata: metadata}}
end end
test "returns the filepath", %{media_item: media_item, metadata: metadata} do test "returns nil if yt-dlp fails", %{media_item: media_item} do
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata) stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:error, "error"} end)
assert filepath =~ ~r{/media_items/#{media_item.id}/maxresdefault.jpg} filepath = Helpers.download_and_store_thumbnail_for(media_item)
end
test "creates folder structure based on passed record", %{media_item: media_item, metadata: metadata} do
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert File.exists?(Path.dirname(filepath))
end
test "chooses the highest preference jpg thumbnail available", %{media_item: media_item} do
metadata = %{
"thumbnails" => [
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.jpg", "preference" => -1},
%{"url" => "https://i.ytimg.com/vi/ABC123/img_2.jpg", "preference" => 1},
%{"url" => "https://i.ytimg.com/vi/ABC123/img_3.jpg", "preference" => -10},
%{"url" => "https://i.ytimg.com/vi/ABC123/img_4.webp", "preference" => 10}
]
}
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert filepath =~ ~r{/media_items/#{media_item.id}/img_2.jpg}
end
test "will fall back to a non-jpg if it has to", %{media_item: media_item} do
metadata = %{
"thumbnails" => [
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp", "preference" => -1}
]
}
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
end
test "does not require a preference field", %{media_item: media_item} do
metadata = %{
"thumbnails" => [
%{"url" => "https://i.ytimg.com/vi/ABC123/img_1.webp"}
]
}
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert filepath =~ ~r{/media_items/#{media_item.id}/img_1.webp}
end
end
describe "download_and_store_thumbnail_for/2 when not downloading thumbnails" do
test "returns nil if there are no thumbnails", %{media_item: media_item} do
metadata = %{"thumbnails" => []}
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert filepath == nil
end
test "returns nil if there is no thumbnail field", %{media_item: media_item} do
metadata = %{}
filepath = Helpers.download_and_store_thumbnail_for(media_item, metadata)
assert filepath == nil assert filepath == nil
end end
end end
describe "parse_upload_date/1" do describe "parse_upload_date/1" do
test "returns a date from the given metadata upload date" do test "returns a datetime from the given metadata upload date" do
upload_date = "20210101" upload_date = "20210101"
assert Helpers.parse_upload_date(upload_date) == ~D[2021-01-01] assert Helpers.parse_upload_date(upload_date) == ~U[2021-01-01 00:00:00Z]
end end
end end
@@ -26,9 +26,9 @@ defmodule Pinchflat.Podcasts.PodcastHelpersTest do
test "orders by upload date where newest is first" do test "orders by upload date where newest is first" do
source = source_fixture() source = source_fixture()
oldest = media_item_with_attachments(%{source_id: source.id, upload_date: now_minus(2, :day)}) oldest = media_item_with_attachments(%{source_id: source.id, uploaded_at: now_minus(2, :day)})
current = media_item_with_attachments(%{source_id: source.id, upload_date: now()}) current = media_item_with_attachments(%{source_id: source.id, uploaded_at: now()})
older = media_item_with_attachments(%{source_id: source.id, upload_date: now_minus(1, :days)}) older = media_item_with_attachments(%{source_id: source.id, uploaded_at: now_minus(1, :days)})
assert [^current, ^older, ^oldest] = PodcastHelpers.persisted_media_items_for(source) assert [^current, ^older, ^oldest] = PodcastHelpers.persisted_media_items_for(source)
end end
@@ -117,8 +117,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilderTest do
assert String.contains?(item_xml, ~s(<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>)) assert String.contains?(item_xml, ~s(<itunes:summary><![CDATA[#{media_item.description}]]></itunes:summary>))
end end
test "returns pubDate based off the media's upload_date", %{source: source} do test "returns pubDate based off the media's uploaded_at", %{source: source} do
media_item_with_attachments(%{source_id: source.id, upload_date: ~D[2020-01-01]}) media_item_with_attachments(%{source_id: source.id, uploaded_at: ~U[2020-01-01 00:00:00Z]})
res = RssFeedBuilder.build(source) res = RssFeedBuilder.build(source)
[_before, item_xml, _after] = String.split(res, ~r(</?item>)) [_before, item_xml, _after] = String.split(res, ~r(</?item>))
-60
View File
@@ -78,64 +78,4 @@ defmodule Pinchflat.SettingsTest do
assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true}) assert %Ecto.Changeset{} = Settings.change_setting(setting, %{onboarding: true})
end end
end end
describe "change_setting/2 when testing codec preferences" do
test "converts (video|audio)_codec_preference_string to an array" do
setting = Settings.record()
new_setting = %{
video_codec_preference_string: "avc>vp9",
audio_codec_preference_string: "aac>opus"
}
changeset = Settings.change_setting(setting, new_setting)
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
end
test "removes whitespace from (video|audio)_codec_preference" do
setting = Settings.record()
new_setting = %{
video_codec_preference_string: " avc > > vp9 ",
audio_codec_preference_string: "aac> opus "
}
changeset = Settings.change_setting(setting, new_setting)
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
end
test "downcases (video|audio)_codec_preference" do
setting = Settings.record()
new_setting = %{
video_codec_preference_string: "AVC>VP9",
audio_codec_preference_string: "AAC>OPUS"
}
changeset = Settings.change_setting(setting, new_setting)
assert ["avc", "vp9"] = changeset.changes.video_codec_preference
assert ["aac", "opus"] = changeset.changes.audio_codec_preference
end
test "an empty value will remove the codec settings" do
Settings.set(video_codec_preference: ["avc", "vp9"])
Settings.set(audio_codec_preference: ["aac", "opus"])
setting = Settings.record()
new_setting = %{
video_codec_preference_string: "",
audio_codec_preference_string: ""
}
changeset = Settings.change_setting(setting, new_setting)
assert [] = changeset.changes.video_codec_preference
assert [] = changeset.changes.audio_codec_preference
end
end
end end
+55
View File
@@ -659,6 +659,61 @@ defmodule Pinchflat.SourcesTest do
end end
end end
describe "change_source/3 when testing original_url validation" do
test "succeeds when an original URL is valid" do
source = source_fixture()
valid_urls = [
"https://www.youtube.com/channel/UCkRfArvrzheW2E7b6SVT7vQ",
"https://www.youtube.com/channel/UCkRfArvrzheW2E7b6SVT7vQ/videos",
"https://www.youtube.com/@youtubecreators/featured",
"https://www.youtube.com/@youtubecreators",
"https://www.youtube.com/c/YouTubeCreators",
"https://www.youtube.com/user/YouTubeCreators",
"https://www.youtube.com/YouTubeCreators",
"https://www.youtube.com/playlist?list=PLpjK416fmKwRtq-9-O_NbZlkW0k6zu2Wn",
"https://www.youtube.com/playlist?list=UUkRfArvrzheW2E7b6SVT7vQ"
]
Enum.each(valid_urls, fn url ->
assert %{errors: []} = Sources.change_source(source, %{original_url: url})
end)
end
test "fails when an original URL points to a video" do
source = source_fixture()
invalid_urls = [
"https://www.youtube.com/watch?v=72maj9FLQZI",
"https://youtu.be/72maj9FLQZI",
"https://www.youtube.com/watch?v=1FwGFhMAmBo&list=PLpjK416fmKwRtq-9-O_NbZlkW0k6zu2Wn",
"https://www.youtube.com/shorts/Dq0eH-ZhQTU",
"https://www.youtube.com/embed/X64LHlfx4qg"
]
Enum.each(invalid_urls, fn url ->
assert %{errors: [_]} = Sources.change_source(source, %{original_url: url})
end)
end
test "passes when a non-youtube link is provided" do
source = source_fixture()
valid_urls = [
"https://www.example.com",
"https://www.example.com/playlist",
"https://www.example.com/channel",
"https://www.example.com/user",
"https://www.example.com/watch?v=72maj9FLQZI",
"https://www.example.com/embed/X64LHlfx4qg"
]
Enum.each(valid_urls, fn url ->
assert %{errors: []} = Sources.change_source(source, %{original_url: url})
end)
end
end
defp playlist_mock(_url, _opts, _ot) do defp playlist_mock(_url, _opts, _ot) do
{ {
:ok, :ok,
@@ -1,14 +0,0 @@
defmodule Pinchflat.Utils.DatetimeUtilsTest do
use Pinchflat.DataCase
alias Pinchflat.Utils.DatetimeUtils
describe "date_to_datetime/1" do
test "converts a Date to a DateTime" do
date = ~D[2022-01-01]
datetime = DatetimeUtils.date_to_datetime(date)
assert datetime == ~U[2022-01-01 00:00:00Z]
end
end
end
@@ -16,4 +16,35 @@ defmodule Pinchflat.Utils.NumberUtilsTest do
assert NumberUtils.clamp(2, 1, 3) == 2 assert NumberUtils.clamp(2, 1, 3) == 2
end end
end end
describe "human_byte_size/1" do
test "converts byte size to human readable format" do
assert NumberUtils.human_byte_size(1024) == {1, "KB"}
assert NumberUtils.human_byte_size(1024 * 1024) == {1, "MB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024) == {1, "GB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024 * 1024) == {1, "TB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024 * 1024 * 1024) == {1, "PB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024 * 1024 * 1024 * 1024) == {1, "EB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024) == {1, "ZB"}
assert NumberUtils.human_byte_size(1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024) == {1, "YB"}
end
test "returns the number when it is less than 1024" do
assert NumberUtils.human_byte_size(512) == {512, "B"}
end
test "optionally takes a precision" do
assert NumberUtils.human_byte_size(1234 * 1024, precision: 0) == {1, "MB"}
assert NumberUtils.human_byte_size(1234 * 1024, precision: 1) == {1.2, "MB"}
assert NumberUtils.human_byte_size(1234 * 1024, precision: 2) == {1.21, "MB"}
end
test "handles 0's well" do
assert NumberUtils.human_byte_size(0) == {0, "B"}
end
test "handles nil well" do
assert NumberUtils.human_byte_size(nil) == {0, "B"}
end
end
end end
+96 -33
View File
@@ -7,8 +7,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
@media_url "https://www.youtube.com/watch?v=TiZPUDkDYbk" @media_url "https://www.youtube.com/watch?v=TiZPUDkDYbk"
describe "download/2" do describe "download/3" do
test "it calls the backend runner with the expected arguments" do test "calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, addl -> expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot, addl ->
assert [:no_simulate] = opts assert [:no_simulate] = opts
assert "after_move:%()j" = ot assert "after_move:%()j" = ot
@@ -20,7 +20,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert {:ok, _} = Media.download(@media_url) assert {:ok, _} = Media.download(@media_url)
end end
test "it passes along additional options" do test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, addl -> expect(YtDlpRunnerMock, :run, fn _url, opts, _ot, addl ->
assert [:no_simulate, :custom_arg] = opts assert [:no_simulate, :custom_arg] = opts
assert [addl_arg: true] = addl assert [addl_arg: true] = addl
@@ -31,7 +31,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert {:ok, _} = Media.download(@media_url, [:custom_arg], addl_arg: true) assert {:ok, _} = Media.download(@media_url, [:custom_arg], addl_arg: true)
end end
test "it parses and returns the generated file as JSON" do test "parses and returns the generated file as JSON" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot, _addl ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -40,7 +40,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
Media.download(@media_url) Media.download(@media_url)
end end
test "it returns errors" do test "returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl -> expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot, _addl ->
{:error, "something"} {:error, "something"}
end) end)
@@ -49,6 +49,37 @@ defmodule Pinchflat.YtDlp.MediaTest do
end end
end end
describe "download_thumbnail/2" do
test "calls the backend runner with the expected arguments" do
expect(YtDlpRunnerMock, :run, fn @media_url, opts, ot ->
assert opts == [:no_simulate, :skip_download, :write_thumbnail, {:convert_thumbnail, "jpg"}]
assert ot == "after_move:%()j"
{:ok, ""}
end)
assert {:ok, _} = Media.download_thumbnail(@media_url)
end
test "passes along additional options" do
expect(YtDlpRunnerMock, :run, fn _url, opts, _ot ->
assert :custom_arg in opts
{:ok, "{}"}
end)
assert {:ok, _} = Media.download_thumbnail(@media_url, [:custom_arg])
end
test "returns errors" do
expect(YtDlpRunnerMock, :run, fn _url, _opt, _ot ->
{:error, "something"}
end)
assert {:error, "something"} = Media.download_thumbnail(@media_url)
end
end
describe "get_media_attributes/1" do describe "get_media_attributes/1" do
test "returns a list of video attributes" do test "returns a list of video attributes" do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
@@ -79,7 +110,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "indexing_output_template/0" do describe "indexing_output_template/0" do
test "contains all the greatest hits" do test "contains all the greatest hits" do
assert "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date})j" == assert "%(.{id,title,was_live,webpage_url,description,aspect_ratio,duration,upload_date,timestamp})j" ==
Media.indexing_output_template() Media.indexing_output_template()
end end
end end
@@ -94,7 +125,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
"was_live" => false, "was_live" => false,
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 60, "duration" => 60,
"upload_date" => "20210101" "upload_date" => "20210101",
"timestamp" => 1_600_000_000
} }
assert %Media{ assert %Media{
@@ -104,7 +136,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk", original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
livestream: false, livestream: false,
short_form_content: false, short_form_content: false,
upload_date: Date.from_iso8601!("2021-01-01"), uploaded_at: ~U[2020-09-13 12:26:40Z],
duration_seconds: 60 duration_seconds: 60
} == Media.response_to_struct(response) } == Media.response_to_struct(response)
end end
@@ -146,34 +178,11 @@ defmodule Pinchflat.YtDlp.MediaTest do
response = %{ response = %{
"webpage_url" => nil, "webpage_url" => nil,
"aspect_ratio" => nil, "aspect_ratio" => nil,
"duration" => nil "duration" => nil,
}
assert %Media{short_form_content: nil} = Media.response_to_struct(response)
end
test "parses the upload date" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101" "upload_date" => "20210101"
} }
expected_date = Date.from_iso8601!("2021-01-01") assert %Media{short_form_content: nil} = Media.response_to_struct(response)
assert %Media{upload_date: ^expected_date} = Media.response_to_struct(response)
end
test "doesn't blow up if upload date is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => nil
}
assert %Media{upload_date: nil} = Media.response_to_struct(response)
end end
test "parses the duration" do test "parses the duration" do
@@ -209,4 +218,58 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{livestream: false} = Media.response_to_struct(response) assert %Media{livestream: false} = Media.response_to_struct(response)
end end
end end
describe "response_to_struct/1 when testing uploaded_at" do
test "parses the upload date from the timestamp if present" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101",
"timestamp" => 1_600_000_000
}
expected_date = ~U[2020-09-13 12:26:40Z]
assert %Media{uploaded_at: ^expected_date} = Media.response_to_struct(response)
end
test "parses the upload date from the uploaded_at if timestamp is present but nil" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101",
"timestamp" => nil
}
expected_date = ~U[2021-01-01 00:00:00Z]
assert %Media{uploaded_at: ^expected_date} = Media.response_to_struct(response)
end
test "parses the upload date from the uploaded_at if timestamp absent" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => "20210101"
}
expected_date = ~U[2021-01-01 00:00:00Z]
assert %Media{uploaded_at: ^expected_date} = Media.response_to_struct(response)
end
test "doesn't blow up if upload date is missing" do
response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0,
"duration" => 61,
"upload_date" => nil
}
assert %Media{uploaded_at: nil} = Media.response_to_struct(response)
end
end
end end
@@ -14,6 +14,13 @@ defmodule PinchflatWeb.MediaItemControllerTest do
assert html_response(conn, 200) =~ "#{media_item.title}" assert html_response(conn, 200) =~ "#{media_item.title}"
end end
test "renders the page when the media item has no description", %{conn: conn} do
media_item = media_item_with_attachments(%{description: nil})
conn = get(conn, ~p"/sources/#{media_item.source_id}/media/#{media_item}")
assert html_response(conn, 200) =~ "#{media_item.title}"
end
end end
describe "edit media" do describe "edit media" do
@@ -166,6 +173,7 @@ defmodule PinchflatWeb.MediaItemControllerTest do
assert conn.status == 206 assert conn.status == 206
assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers
assert {"content-length", "101"} in conn.resp_headers assert {"content-length", "101"} in conn.resp_headers
assert {"content-disposition", "inline; filename=\"#{media_item.title}\""} in conn.resp_headers
end end
test "streams the specified range", %{conn: conn, media_item: media_item} do test "streams the specified range", %{conn: conn, media_item: media_item} do
@@ -241,6 +249,8 @@ defmodule PinchflatWeb.MediaItemControllerTest do
assert conn.status == 200 assert conn.status == 200
assert {"content-length", to_string(filesize)} in conn.resp_headers assert {"content-length", to_string(filesize)} in conn.resp_headers
assert {"content-range", "bytes 0-#{filesize - 1}/#{filesize}"} in conn.resp_headers
assert {"content-disposition", "inline; filename=\"#{media_item.title}\""} in conn.resp_headers
end end
test "streams the entire file", %{conn: conn, media_item: media_item} do test "streams the entire file", %{conn: conn, media_item: media_item} do
@@ -4,6 +4,7 @@ defmodule PinchflatWeb.Sources.MediaItemTableLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures
alias Pinchflat.Sources.MediaItemTableLive alias Pinchflat.Sources.MediaItemTableLive
@@ -26,7 +27,7 @@ defmodule PinchflatWeb.Sources.MediaItemTableLiveTest do
{:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source)) {:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source))
assert html =~ "Showing 1 of 1" assert html =~ "Showing"
assert html =~ "Title" assert html =~ "Title"
assert html =~ media_item.title assert html =~ media_item.title
end end
@@ -34,10 +35,8 @@ defmodule PinchflatWeb.Sources.MediaItemTableLiveTest do
describe "media_state" do describe "media_state" do
test "shows pending media when pending", %{conn: conn, source: source} do test "shows pending media when pending", %{conn: conn, source: source} do
downloaded_media_item = media_item_fixture(source_id: source.id, title: "DL-#{Enum.random(0..9999)}") downloaded_media_item = media_item_fixture(source_id: source.id)
pending_media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
pending_media_item =
media_item_fixture(source_id: source.id, media_filepath: nil, title: "P-#{Enum.random(0..9999)}")
{:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source, "pending")) {:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source, "pending"))
@@ -54,6 +53,29 @@ defmodule PinchflatWeb.Sources.MediaItemTableLiveTest do
assert html =~ downloaded_media_item.title assert html =~ downloaded_media_item.title
refute html =~ pending_media_item.title refute html =~ pending_media_item.title
end end
test "shows records that aren't pending or downloaded when other", %{conn: conn} do
media_profile = media_profile_fixture(shorts_behaviour: :exclude)
source = source_fixture(media_profile_id: media_profile.id)
downloaded_media_item = media_item_fixture(source_id: source.id)
pending_media_item = media_item_fixture(source_id: source.id, media_filepath: nil)
other_media_item = media_item_fixture(source_id: source.id, media_filepath: nil, short_form_content: true)
{:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source, "other"))
assert html =~ other_media_item.title
refute html =~ downloaded_media_item.title
refute html =~ pending_media_item.title
end
test "shows 'Manually Ignored' column when other", %{conn: conn, source: source} do
_media_item = media_item_fixture(source_id: source.id, prevent_download: true, media_filepath: nil)
{:ok, _view, html} = live_isolated(conn, MediaItemTableLive, session: create_session(source, "other"))
assert html =~ "Manually Ignored?"
end
end end
defp create_session(source, media_state \\ "pending") do defp create_session(source, media_state \\ "pending") do
+1 -1
View File
@@ -9637,7 +9637,7 @@
"uploader_url": "https://www.youtube.com/@PinchflatTestChannel", "uploader_url": "https://www.youtube.com/@PinchflatTestChannel",
"upload_date": "20210720", "upload_date": "20210720",
"availability": "public", "availability": "public",
"original_url": "ABC123", "original_url": "https://www.youtube.com/watch?v=ABC123",
"webpage_url_basename": "watch", "webpage_url_basename": "watch",
"webpage_url_domain": "youtube.com", "webpage_url_domain": "youtube.com",
"extractor": "youtube", "extractor": "youtube",
+3 -2
View File
@@ -23,7 +23,7 @@ defmodule Pinchflat.MediaFixtures do
short_form_content: false, short_form_content: false,
media_filepath: "/video/#{Faker.File.file_name(:video)}", media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: SourcesFixtures.source_fixture().id, source_id: SourcesFixtures.source_fixture().id,
upload_date: DateTime.utc_now() uploaded_at: DateTime.utc_now()
}) })
|> Pinchflat.Media.create_media_item() |> Pinchflat.Media.create_media_item()
@@ -97,7 +97,8 @@ defmodule Pinchflat.MediaFixtures do
description: "desc1", description: "desc1",
aspect_ratio: 1.67, aspect_ratio: 1.67,
duration: 123.45, duration: 123.45,
upload_date: "20210101" upload_date: "20210101",
timestamp: 1_600_000_000
} }
Phoenix.json_library().encode!(media_attributes) Phoenix.json_library().encode!(media_attributes)
+1 -1
View File
@@ -25,7 +25,7 @@ defmodule Pinchflat.SourcesFixtures do
collection_type: "channel", collection_type: "channel",
custom_name: "Cool and good internal name!", custom_name: "Cool and good internal name!",
description: "This is a description", description: "This is a description",
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}", original_url: "https://www.youtube.com/@#{Faker.String.base64(12)}",
media_profile_id: ProfilesFixtures.media_profile_fixture().id, media_profile_id: ProfilesFixtures.media_profile_fixture().id,
index_frequency_minutes: 60 index_frequency_minutes: 60
} }
+4 -3
View File
@@ -780,9 +780,10 @@ sprintf-js@^1.1.3:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
"sqleton@https://github.com/kieraneglin/sqleton#ke/add-index-support": sqleton@^2.2.0:
version "2.1.0" version "2.2.0"
resolved "https://github.com/kieraneglin/sqleton#b066b39fd9f7caff7cc86d1d7d37088e74ae44f2" resolved "https://registry.yarnpkg.com/sqleton/-/sqleton-2.2.0.tgz#d265b625c43ec552b5d3275c25b85dfec240d910"
integrity sha512-pfjBQRmrRNi4DEiX5X1akyLIG9z6UiG7Hi+5vwB/dG1ksgJ8yuL5na+3HI5nZqaWBJf91L340x55ZLdake9/yg==
dependencies: dependencies:
sqlite3 "^5.1.4" sqlite3 "^5.1.4"
yargs "^17.2.1" yargs "^17.2.1"