Compare commits
39 Commits
v2024.5.15
...
v2024.6.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 9daef8b3b8 | |||
| f6708a327c | |||
| 582eb53698 | |||
| a20d06072f | |||
| 6d18130351 | |||
| af86ca1e0e | |||
| 4994e70652 | |||
| 1c9f8f6abc | |||
| 2ae1ee1c99 | |||
| b73a9a4a67 | |||
| 3dd901ff3e | |||
| e751c65424 | |||
| 22439e0273 | |||
| 7603ba017f | |||
| 10880e9699 | |||
| b2e16d50cd | |||
| 3a5c06fe64 | |||
| 99fc2eb8f8 | |||
| e824b1a9f5 | |||
| 95a0c29358 | |||
| 8b0b41186a | |||
| d2f91a8253 | |||
| 776b84c585 | |||
| 29803c9b26 | |||
| 85be8744d4 | |||
| 1cbc62dc27 | |||
| 5af22d3a2f | |||
| bf917febe8 | |||
| 8189dcce71 | |||
| 4e4092c792 | |||
| 0cd6ac797b | |||
| 81d5efd4c1 | |||
| dec3938780 | |||
| a2a0f48065 | |||
| 94c0cf9970 | |||
| bbdc56c656 | |||
| 7bf74a04e7 | |||
| 33040bba34 | |||
| b1a6a6a81f |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Other
|
||||
about: For everything else
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: kieraneglin
|
||||
---
|
||||
@@ -11,6 +11,12 @@ on:
|
||||
options:
|
||||
- 'linux/amd64'
|
||||
- 'linux/amd64,linux/arm64'
|
||||
docker_tags:
|
||||
type: string
|
||||
description: 'Docker Tags'
|
||||
required: true
|
||||
default: 'dev'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -41,7 +47,8 @@ jobs:
|
||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||
tags: |
|
||||
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: |
|
||||
latest=auto
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- [Unraid](#unraid)
|
||||
- [Portainer](#portainer)
|
||||
- [Docker](#docker)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Username and Password (authentication)](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password)
|
||||
- [Frequently asked questions](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions)
|
||||
- [Documentation](https://github.com/kieraneglin/pinchflat/wiki)
|
||||
@@ -90,7 +91,7 @@ Docker Compose file:
|
||||
version: '3'
|
||||
services:
|
||||
pinchflat:
|
||||
image: keglin/pinchflat:latest
|
||||
image: ghcr.io/kieraneglin/pinchflat:latest
|
||||
environment:
|
||||
# Set the timezone to your local timezone
|
||||
- TZ=America/New_York
|
||||
@@ -141,6 +142,19 @@ As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), S
|
||||
|
||||
If you change this setting and it works well for you, please leave a comment on [#137](https://github.com/kieraneglin/pinchflat/issues/137)! Doubly so if it does _not_ work well.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Name | Required? | Default | Notes |
|
||||
| --------------------- | --------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| TZ | No | `UTC` | Must follow IANA TZ format |
|
||||
| LOG_LEVEL | No | `debug` | Can be set to `info` |
|
||||
| BASIC_AUTH_USERNAME | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||
| BASIC_AUTH_PASSWORD | No | | See [authentication docs](https://github.com/kieraneglin/pinchflat/wiki/Username-and-Password) |
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
Prior to 2024-05-10, a portion of all donations were given to the [Electronic Frontier Foundation](https://www.eff.org/). Now, the app doesn't accept donations that go to me personally and instead directs you straight to the EFF. [Here](https://github.com/kieraneglin/pinchflat/issues/234) are some people that have generously donated.
|
||||
|
||||
+21
-18
@@ -22,8 +22,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-Light.woff2'), url('/fonts/satoshi/Satoshi-Light.woff'),
|
||||
url('/fonts/satoshi/Satoshi-Light.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-Light.woff2'), url('../fonts/satoshi/Satoshi-Light.woff'),
|
||||
url('../fonts/satoshi/Satoshi-Light.ttf');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
@@ -31,8 +31,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-LightItalic.woff2'),
|
||||
url('/fonts/satoshi/Satoshi-LightItalic.woff'), url('/fonts/satoshi/Satoshi-LightItalic.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-LightItalic.woff2'),
|
||||
url('../fonts/satoshi/Satoshi-LightItalic.woff'),
|
||||
url('../fonts/satoshi/Satoshi-LightItalic.ttf');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
@@ -40,8 +41,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-Regular.woff2'), url('/fonts/satoshi/Satoshi-Regular.woff'),
|
||||
url('/fonts/satoshi/Satoshi-Regular.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-Regular.woff2'), url('../fonts/satoshi/Satoshi-Regular.woff'),
|
||||
url('../fonts/satoshi/Satoshi-Regular.ttf');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
@@ -49,8 +50,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-Italic.woff2'), url('/fonts/satoshi/Satoshi-Italic.woff'),
|
||||
url('/fonts/satoshi/Satoshi-Italic.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-Italic.woff2'), url('../fonts/satoshi/Satoshi-Italic.woff'),
|
||||
url('../fonts/satoshi/Satoshi-Italic.ttf');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
@@ -58,8 +59,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-Medium.woff2'), url('/fonts/satoshi/Satoshi-Medium.woff'),
|
||||
url('/fonts/satoshi/Satoshi-Medium.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-Medium.woff2'), url('../fonts/satoshi/Satoshi-Medium.woff'),
|
||||
url('../fonts/satoshi/Satoshi-Medium.ttf');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
@@ -67,8 +68,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
||||
url('/fonts/satoshi/Satoshi-MediumItalic.woff'), url('/fonts/satoshi/Satoshi-MediumItalic.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-MediumItalic.woff2'),
|
||||
url('../fonts/satoshi/Satoshi-MediumItalic.woff'),
|
||||
url('../fonts/satoshi/Satoshi-MediumItalic.ttf');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
@@ -76,8 +78,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-Bold.woff2'), url('/fonts/satoshi/Satoshi-Bold.woff'),
|
||||
url('/fonts/satoshi/Satoshi-Bold.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-Bold.woff2'), url('../fonts/satoshi/Satoshi-Bold.woff'),
|
||||
url('../fonts/satoshi/Satoshi-Bold.ttf');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
@@ -85,8 +87,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-BoldItalic.woff2'), url('/fonts/satoshi/Satoshi-BoldItalic.woff'),
|
||||
url('/fonts/satoshi/Satoshi-BoldItalic.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-BoldItalic.woff2'),
|
||||
url('../fonts/satoshi/Satoshi-BoldItalic.woff'), url('../fonts/satoshi/Satoshi-BoldItalic.ttf');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
@@ -94,8 +96,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
||||
url('/fonts/satoshi/Satoshi-BlackItalic.woff'), url('/fonts/satoshi/Satoshi-BlackItalic.ttf');
|
||||
src: url('../fonts/satoshi/Satoshi-BlackItalic.woff2'),
|
||||
url('../fonts/satoshi/Satoshi-BlackItalic.woff'),
|
||||
url('../fonts/satoshi/Satoshi-BlackItalic.ttf');
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
|
||||
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 },
|
||||
dom: {
|
||||
onBeforeElUpdated(from, to) {
|
||||
|
||||
+7
-7
@@ -1,20 +1,20 @@
|
||||
window.setTabIndex = (index) => {
|
||||
window.location.hash = `tab-${index}`
|
||||
window.setTabByName = (tabName) => {
|
||||
window.location.hash = `tab-${tabName}`
|
||||
|
||||
return index
|
||||
return tabName
|
||||
}
|
||||
|
||||
// The conditionals and currIndex stuff ensures that
|
||||
// the tab index is always set to 0 if the hash is empty
|
||||
// AND other hash values are ignored
|
||||
window.getTabIndex = (currIndex) => {
|
||||
window.getTabFromHash = (currentTabName, defaultTabName) => {
|
||||
if (window.location.hash === '' || window.location.hash === '#') {
|
||||
return 0
|
||||
return defaultTabName
|
||||
}
|
||||
|
||||
if (window.location.hash.startsWith('#tab-')) {
|
||||
return parseInt(window.location.hash.replace('#tab-', ''))
|
||||
return window.location.hash.replace('#tab-', '')
|
||||
}
|
||||
|
||||
return currIndex
|
||||
return currentTabName
|
||||
}
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ config :pinchflat,
|
||||
basic_auth_password: "",
|
||||
expose_feed_endpoints: false,
|
||||
file_watcher_poll_interval: 1000,
|
||||
timezone: "UTC"
|
||||
timezone: "UTC",
|
||||
base_route_path: "/"
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
journal_mode: :wal,
|
||||
|
||||
+5
-3
@@ -46,12 +46,12 @@ if config_env() == :prod do
|
||||
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"]))
|
||||
extras_path = System.get_env("EXTRAS_PATH", Path.join([config_path, "extras"]))
|
||||
|
||||
# For running PF as a podcast host on self-hosted environments
|
||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||
|
||||
# For testing alternate journal modes (see issue #137)
|
||||
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"))
|
||||
|
||||
@@ -65,7 +65,8 @@ if config_env() == :prod do
|
||||
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
|
||||
expose_feed_endpoints: expose_feed_endpoints,
|
||||
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")
|
||||
|
||||
@@ -108,6 +109,7 @@ if config_env() == :prod do
|
||||
ip: {0, 0, 0, 0},
|
||||
port: String.to_integer(System.get_env("PORT") || "4000")
|
||||
],
|
||||
url: [path: base_route_path],
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
config :pinchflat, :logger, [
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ RUN echo "Building for ${TARGETPLATFORM:?}"
|
||||
RUN apt-get update -qq
|
||||
RUN apt-get install -y inotify-tools curl git openssh-client jq \
|
||||
python3 python3-setuptools python3-wheel python3-dev pipx \
|
||||
python3-mutagen locales procps build-essential
|
||||
python3-mutagen locales procps build-essential graphviz
|
||||
|
||||
# Install ffmpeg
|
||||
RUN export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
|
||||
|
||||
@@ -12,3 +12,5 @@ services:
|
||||
- ./docker-run.dev.sh
|
||||
stdin_open: true
|
||||
tty: true
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
- Write media datbase ID as metadata/to file/whatever so it gives us an option to retroactively match media to the DB down the line. Useful if someone moves the media without informing the UI
|
||||
- Use a UUID for the media database ID (or at least alongside it)
|
||||
- Look into this and its recommended plugins https://hexdocs.pm/ex_check/readme.html
|
||||
- Add output template option for the source's friendly name
|
||||
@@ -24,7 +24,7 @@ defmodule Pinchflat.Application do
|
||||
PinchflatWeb.Endpoint
|
||||
]
|
||||
|
||||
:ok = Oban.Telemetry.attach_default_logger()
|
||||
attach_oban_telemetry()
|
||||
Logger.add_handlers(:pinchflat)
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
@@ -40,4 +40,11 @@ defmodule Pinchflat.Application do
|
||||
PinchflatWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp attach_oban_telemetry do
|
||||
events = [[:oban, :job, :start], [:oban, :job, :stop], [:oban, :job, :exception]]
|
||||
|
||||
:ok = Oban.Telemetry.attach_default_logger()
|
||||
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,6 +31,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
"""
|
||||
@impl true
|
||||
def init(state) do
|
||||
ensure_tmpfile_directory()
|
||||
reset_executing_jobs()
|
||||
create_blank_yt_dlp_files()
|
||||
create_blank_user_script_file()
|
||||
@@ -39,6 +40,14 @@ defmodule Pinchflat.Boot.PreJobStartupTasks do
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp ensure_tmpfile_directory do
|
||||
tmpfile_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
|
||||
if !File.exists?(tmpfile_dir) do
|
||||
File.mkdir_p!(tmpfile_dir)
|
||||
end
|
||||
end
|
||||
|
||||
# If a node cannot gracefully shut down, the currently executing jobs get stuck
|
||||
# in the "executing" state. This is a problem because the job runner will not
|
||||
# pick them up again
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
"""
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.OutputPathBuilder
|
||||
@@ -121,27 +122,25 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
end
|
||||
|
||||
defp quality_options(media_profile) do
|
||||
video_codec_option = fn res ->
|
||||
[format_sort: "res:#{res},+codec:avc:m4a", remux_video: "mp4"]
|
||||
end
|
||||
|
||||
audio_format_precedence = [
|
||||
"bestaudio[ext=m4a]",
|
||||
"bestaudio[ext=mp3]",
|
||||
"bestaudio",
|
||||
"best[ext=m4a]",
|
||||
"best[ext=mp3]",
|
||||
"best"
|
||||
]
|
||||
vcodec = Settings.get!(:video_codec_preference)
|
||||
acodec = Settings.get!(:audio_codec_preference)
|
||||
|
||||
case media_profile.preferred_resolution do
|
||||
# Also be aware that :audio disabled all embedding options for subtitles
|
||||
:audio -> [:extract_audio, format: Enum.join(audio_format_precedence, "/")]
|
||||
:"360p" -> video_codec_option.("360")
|
||||
:"480p" -> video_codec_option.("480")
|
||||
:"720p" -> video_codec_option.("720")
|
||||
:"1080p" -> video_codec_option.("1080")
|
||||
:"2160p" -> video_codec_option.("2160")
|
||||
:audio ->
|
||||
[:extract_audio, format_sort: "+acodec:#{acodec}"]
|
||||
|
||||
resolution_atom ->
|
||||
{resolution_string, _} =
|
||||
resolution_atom
|
||||
|> Atom.to_string()
|
||||
|> Integer.parse()
|
||||
|
||||
[
|
||||
# Since Plex doesn't support reading metadata from MKV
|
||||
remux_video: "mp4",
|
||||
format_sort: "res:#{resolution_string},+codec:#{vcodec}:#{acodec}"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -85,10 +85,16 @@ defmodule Pinchflat.Downloading.DownloadingHelpers do
|
||||
"""
|
||||
def kickoff_redownload_for_existing_media(%Source{} = source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_downloaded_at()
|
||||
|> MediaQuery.where_download_not_prevented()
|
||||
|> MediaQuery.where_not_culled()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(
|
||||
^dynamic(
|
||||
[m, s, mp],
|
||||
^MediaQuery.for_source(source) and
|
||||
^MediaQuery.downloaded() and
|
||||
not (^MediaQuery.download_prevented()) and
|
||||
not (^MediaQuery.culled())
|
||||
)
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(&MediaDownloadWorker.kickoff_with_task/1)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorker do
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ defmodule Pinchflat.Downloading.MediaDownloader do
|
||||
# IDEA: might be worth kicking off a job for this since thumbnail fetching
|
||||
# could fail and I want to handle that in isolation
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorker do
|
||||
use Oban.Worker,
|
||||
queue: :media_fetching,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable, :executing]],
|
||||
tags: ["media_item", "media_fetching"]
|
||||
tags: ["media_item", "media_fetching", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.FastIndexing.YoutubeApi
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@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
|
||||
order of operations and how this fits into the indexing process.
|
||||
|
||||
@@ -26,7 +27,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
downloaded_.
|
||||
"""
|
||||
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)
|
||||
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||
|
||||
@@ -47,10 +48,20 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
Enum.filter(maybe_new_media_items, & &1)
|
||||
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
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_ids(media_ids)
|
||||
|> where(^dynamic([mi], ^MediaQuery.for_source(source) and mi.media_id in ^media_ids))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingWorker do
|
||||
use Oban.Worker,
|
||||
queue: :fast_indexing,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "fast_indexing"]
|
||||
tags: ["media_source", "fast_indexing", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,18 +1,31 @@
|
||||
defmodule Pinchflat.FastIndexing.YoutubeRss do
|
||||
@moduledoc """
|
||||
Methods for interacting with YouTube RSS feeds
|
||||
Methods for interacting with YouTube RSS feeds for fast indexing
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
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 """
|
||||
Fetches the recent media IDs from a YouTube RSS feed for a given source.
|
||||
|
||||
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}")
|
||||
|
||||
case http_client().get(rss_url_for_source(source)) do
|
||||
|
||||
@@ -21,9 +21,11 @@ defmodule Pinchflat.HTTP.HTTPClient do
|
||||
"""
|
||||
@impl HTTPBehaviour
|
||||
def get(url, headers \\ [], opts \\ []) do
|
||||
headers = parse_headers(headers)
|
||||
|
||||
case :httpc.request(:get, {url, headers}, [], opts) do
|
||||
{:ok, {{_version, 200, _reason_phrase}, _headers, body}} ->
|
||||
{:ok, body}
|
||||
{:ok, to_string(body)}
|
||||
|
||||
{:ok, {{_version, status_code, reason_phrase}, _headers, _body}} ->
|
||||
{: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}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_headers(headers) do
|
||||
Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,15 +63,14 @@ defmodule Pinchflat.Lifecycle.Notifications.SourceNotifications do
|
||||
|
||||
defp pending_media_item_count(source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
defp downloaded_media_item_count(source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
|
||||
@@ -32,9 +32,8 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_cullable_media_items do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> MediaQuery.where_past_retention_period()
|
||||
|> MediaQuery.where_culling_not_prevented()
|
||||
|> MediaQuery.require_assoc(:source)
|
||||
|> where(^MediaQuery.cullable())
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -42,9 +41,10 @@ defmodule Pinchflat.Media do
|
||||
Returns a list of media_items that are redownloadable based on the redownload delay
|
||||
of the media_profile their source belongs to.
|
||||
|
||||
The logic is that a media_item is past_redownload_delay if the media_item's
|
||||
upload_date is at least redownload_delay_days ago AND
|
||||
`media_downloaded_at` - `redownload_delay_days` is before the media_item's `upload_date`.
|
||||
The logic is that a media_item is past_redownload_delay if the media_item's uploaded_at is
|
||||
at least redownload_delay_days ago AND `media_downloaded_at` - `redownload_delay_days`
|
||||
is before the media_item's `uploaded_at`.
|
||||
|
||||
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.
|
||||
This also makes things work as expected when downloading media from a source for the
|
||||
@@ -54,18 +54,14 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_redownloadable_media_items do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_media_downloaded_at()
|
||||
|> MediaQuery.where_download_not_prevented()
|
||||
|> MediaQuery.where_not_culled()
|
||||
|> MediaQuery.where_media_not_redownloaded()
|
||||
|> MediaQuery.where_past_redownload_delay()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^MediaQuery.redownloadable())
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of pending media_items for a given source, where
|
||||
pending means the `media_filepath` is `nil` AND the media_item
|
||||
matches satisfies `MediaQuery.where_pending_download`. You
|
||||
pending means the media_item satisfies `MediaQuery.pending`. You
|
||||
should really check out that function if you need to know more
|
||||
because it has a lot going on.
|
||||
|
||||
@@ -73,15 +69,14 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_pending_media_items_for(%Source{} = source) do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
For a given media_item, tells you if it is pending download. This is defined as
|
||||
the media_item having a `media_filepath` of `nil` and matching the format selection
|
||||
rules of the parent media_profile.
|
||||
the media_item satisfying `MediaQuery.pending` which you should really check out.
|
||||
|
||||
Intentionally does not take the `download_media` setting of the source into account.
|
||||
|
||||
@@ -91,8 +86,8 @@ defmodule Pinchflat.Media do
|
||||
media_item = Repo.preload(media_item, source: :media_profile)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_id(media_item.id)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic([m, s, mp], m.id == ^media_item.id and ^MediaQuery.pending()))
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@@ -141,7 +136,7 @@ defmodule Pinchflat.Media do
|
||||
|
||||
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
|
||||
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{}}
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:livestream,
|
||||
:source_id,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:uploaded_at,
|
||||
:upload_date_index,
|
||||
:duration_seconds,
|
||||
# these fields are captured only on download
|
||||
@@ -51,7 +51,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
livestream
|
||||
media_id
|
||||
source_id
|
||||
upload_date
|
||||
uploaded_at
|
||||
short_form_content
|
||||
)a
|
||||
|
||||
@@ -69,7 +69,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :short_form_content, :boolean, default: false
|
||||
field :media_downloaded_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 :duration_seconds, :integer
|
||||
|
||||
@@ -130,7 +130,31 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
~w(__meta__ __struct__ metadata tasks media_items_search_index)a
|
||||
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 = Sources.get_source!(source_id)
|
||||
# Channels should count down from 99, playlists should count up from 0
|
||||
@@ -142,8 +166,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
|
||||
current_max =
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source_id)
|
||||
|> MediaQuery.where_uploaded_on_date(changes.upload_date)
|
||||
|> where(^dynamic([mi], ^MediaQuery.upload_date_matches(changes.uploaded_at) and ^MediaQuery.for_source(source)))
|
||||
|> Repo.aggregate(aggregator, :upload_date_index)
|
||||
|
||||
case current_max do
|
||||
@@ -152,7 +175,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
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
|
||||
def encode(value, opts) do
|
||||
|
||||
+133
-134
@@ -23,138 +23,31 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
end
|
||||
end
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to a certain record
|
||||
# - join_* - for joining on a certain record
|
||||
# - with_*, where_* - for filtering based on full, concrete attributes
|
||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||
#
|
||||
# Suffixes:
|
||||
# - _for - the arg passed is an association record
|
||||
|
||||
# NOTE: that dyanmic query approach kinda rocked - should refactor in future
|
||||
|
||||
def new do
|
||||
MediaItem
|
||||
end
|
||||
|
||||
def for_source(query, source_id) when is_integer(source_id) do
|
||||
where(query, [mi], mi.source_id == ^source_id)
|
||||
end
|
||||
def for_source(source_id) when is_integer(source_id), do: dynamic([mi], mi.source_id == ^source_id)
|
||||
def for_source(source), do: dynamic([mi], mi.source_id == ^source.id)
|
||||
|
||||
def for_source(query, source) do
|
||||
where(query, [mi], mi.source_id == ^source.id)
|
||||
end
|
||||
def downloaded, do: dynamic([mi], not is_nil(mi.media_filepath))
|
||||
def download_prevented, do: dynamic([mi], mi.prevent_download == true)
|
||||
def culling_prevented, do: dynamic([mi], mi.prevent_culling == true)
|
||||
def culled, do: dynamic([mi], not is_nil(mi.culled_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 join_sources(query) do
|
||||
from(mi in query, join: s in assoc(mi, :source), as: :sources)
|
||||
end
|
||||
|
||||
def where_past_retention_period(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where(
|
||||
def upload_date_after_source_cutoff do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
fragment("""
|
||||
IFNULL(retention_period_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||
""")
|
||||
is_nil(source.download_cutoff_date) or
|
||||
fragment("date(?) >= ?", mi.uploaded_at, source.download_cutoff_date)
|
||||
)
|
||||
end
|
||||
|
||||
def where_past_redownload_delay(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> require_assoc(:media_profile)
|
||||
|> where(
|
||||
[_mi, _source, _media_profile],
|
||||
# Returns media items where the upload_date is at least redownload_delay_days ago AND
|
||||
# downloaded_at minus the redownload_delay_days is before the upload date
|
||||
fragment("""
|
||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || redownload_delay_days || ' day') > upload_date AND
|
||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < upload_date
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def where_culling_not_prevented(query) do
|
||||
where(query, [mi], mi.prevent_culling == false)
|
||||
end
|
||||
|
||||
def where_not_culled(query) do
|
||||
where(query, [mi], is_nil(mi.culled_at))
|
||||
end
|
||||
|
||||
def where_media_not_redownloaded(query) do
|
||||
where(query, [mi], is_nil(mi.media_redownloaded_at))
|
||||
end
|
||||
|
||||
def with_id(query, id) do
|
||||
where(query, [mi], mi.id == ^id)
|
||||
end
|
||||
|
||||
def with_media_ids(query, media_ids) do
|
||||
where(query, [mi], mi.media_id in ^media_ids)
|
||||
end
|
||||
|
||||
def with_media_downloaded_at(query) do
|
||||
where(query, [mi], not is_nil(mi.media_downloaded_at))
|
||||
end
|
||||
|
||||
def with_media_filepath(query) do
|
||||
where(query, [mi], not is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_no_media_filepath(query) do
|
||||
where(query, [mi], is_nil(mi.media_filepath))
|
||||
end
|
||||
|
||||
def with_upload_date_after_source_cutoff(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where([mi, source], is_nil(source.download_cutoff_date) or mi.upload_date >= source.download_cutoff_date)
|
||||
end
|
||||
|
||||
def where_uploaded_on_date(query, date) do
|
||||
where(query, [mi], mi.upload_date == ^date)
|
||||
end
|
||||
|
||||
def where_download_not_prevented(query) do
|
||||
where(query, [mi], mi.prevent_download == false)
|
||||
end
|
||||
|
||||
def matching_source_title_regex(query) do
|
||||
query
|
||||
|> require_assoc(:source)
|
||||
|> where(
|
||||
[mi, source],
|
||||
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||
)
|
||||
end
|
||||
|
||||
def matching_search_term(query, nil), do: query
|
||||
|
||||
def matching_search_term(query, term) do
|
||||
from(mi in query,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")]
|
||||
)
|
||||
end
|
||||
|
||||
def with_format_matching_profile_preference(query) do
|
||||
query
|
||||
|> require_assoc(:media_profile)
|
||||
|> where(
|
||||
def format_matching_profile_preference do
|
||||
dynamic(
|
||||
[mi, source, media_profile],
|
||||
fragment("""
|
||||
CASE
|
||||
WHEN shorts_behaviour = 'only' AND livestream_behaviour = 'only' THEN
|
||||
@@ -176,22 +69,80 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
)
|
||||
end
|
||||
|
||||
def where_pending_download(query) do
|
||||
query
|
||||
|> where_download_not_prevented()
|
||||
|> with_no_media_filepath()
|
||||
|> with_upload_date_after_source_cutoff()
|
||||
|> with_format_matching_profile_preference()
|
||||
|> matching_source_title_regex()
|
||||
def matches_source_title_regex do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
is_nil(source.title_filter_regex) or fragment("regexp_like(?, ?)", mi.title, source.title_filter_regex)
|
||||
)
|
||||
end
|
||||
|
||||
def where_pending_or_downloaded(query) do
|
||||
query
|
||||
|> where_pending_download()
|
||||
|> or_where([mi], not is_nil(mi.media_downloaded_at))
|
||||
def past_retention_period do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
fragment("""
|
||||
IFNULL(retention_period_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || retention_period_days || ' day') > media_downloaded_at
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
defp require_assoc(query, identifier) do
|
||||
def past_redownload_delay do
|
||||
dynamic(
|
||||
[mi, source, media_profile],
|
||||
# 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
|
||||
fragment("""
|
||||
IFNULL(redownload_delay_days, 0) > 0 AND
|
||||
DATETIME('now', '-' || redownload_delay_days || ' day') > uploaded_at AND
|
||||
DATETIME(media_downloaded_at, '-' || redownload_delay_days || ' day') < uploaded_at
|
||||
""")
|
||||
)
|
||||
end
|
||||
|
||||
def cullable do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
^downloaded() and
|
||||
^past_retention_period() and
|
||||
not (^culling_prevented())
|
||||
)
|
||||
end
|
||||
|
||||
def pending do
|
||||
dynamic(
|
||||
[mi],
|
||||
not (^downloaded()) and
|
||||
not (^download_prevented()) and
|
||||
^upload_date_after_source_cutoff() and
|
||||
^format_matching_profile_preference() and
|
||||
^matches_source_title_regex()
|
||||
)
|
||||
end
|
||||
|
||||
def redownloadable do
|
||||
dynamic(
|
||||
[mi, source],
|
||||
^downloaded() and
|
||||
not (^download_prevented()) and
|
||||
not (^culled()) and
|
||||
not (^redownloaded()) and
|
||||
^past_redownload_delay()
|
||||
)
|
||||
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
|
||||
if has_named_binding?(query, identifier) do
|
||||
query
|
||||
else
|
||||
@@ -199,6 +150,10 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
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
|
||||
from(mi in query, join: s in assoc(mi, :source), as: :source)
|
||||
end
|
||||
@@ -208,4 +163,48 @@ defmodule Pinchflat.Media.MediaQuery do
|
||||
|> require_assoc(:source)
|
||||
|> join(:inner, [mi, source], mp in assoc(source, :media_profile), as: :media_profile)
|
||||
end
|
||||
|
||||
# This needs to be a non-dynamic query because it alone should control things like
|
||||
# ordering and `snippets` for full-text search
|
||||
def matching_search_term(query, nil), do: query
|
||||
|
||||
def matching_search_term(query, term) do
|
||||
escaped_term = clean_search_term(term)
|
||||
|
||||
from(mi in query,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^escaped_term),
|
||||
select_merge: %{
|
||||
matching_search_term:
|
||||
fragment("""
|
||||
coalesce(snippet(media_items_search_index, 0, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '') ||
|
||||
' ' ||
|
||||
coalesce(snippet(media_items_search_index, 1, '[PF_HIGHLIGHT]', '[/PF_HIGHLIGHT]', '...', 20), '')
|
||||
""")
|
||||
},
|
||||
order_by: [desc: fragment("rank")]
|
||||
)
|
||||
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
|
||||
|
||||
@@ -11,6 +11,8 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@doc """
|
||||
Returns the directory where metadata for a database record should be stored.
|
||||
|
||||
@@ -54,48 +56,35 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
|
||||
@doc """
|
||||
Downloads and stores a thumbnail for a media item, returning the filepath.
|
||||
Chooses the highest quality thumbnail available (preferring jpg). Returns
|
||||
nil if no thumbnails are available.
|
||||
Chooses the highest quality thumbnail available and converts it to a JPG
|
||||
|
||||
Returns nil if no thumbnail is available or if yt-dlp encounters an error
|
||||
|
||||
Returns binary() | nil
|
||||
"""
|
||||
def download_and_store_thumbnail_for(database_record, metadata_map) do
|
||||
thumbnails =
|
||||
(metadata_map["thumbnails"] || [])
|
||||
# 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
|
||||
def download_and_store_thumbnail_for(database_record) do
|
||||
yt_dlp_filepath = generate_filepath_for(database_record, "thumbnail.%(ext)s")
|
||||
real_filepath = generate_filepath_for(database_record, "thumbnail.jpg")
|
||||
|
||||
Map.put(t, "preference", preference_weight)
|
||||
end)
|
||||
|
||||
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
|
||||
case YtDlpMedia.download_thumbnail(database_record.original_url, output: yt_dlp_filepath) do
|
||||
{:ok, _} -> real_filepath
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
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
|
||||
<<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
|
||||
|
||||
@doc """
|
||||
@@ -135,13 +124,6 @@ defmodule Pinchflat.Metadata.MetadataFileHelpers do
|
||||
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
|
||||
Path.join([
|
||||
metadata_directory_for(database_record),
|
||||
|
||||
@@ -8,6 +8,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
and not have it, ya know?
|
||||
"""
|
||||
|
||||
alias Pinchflat.YtDlp.Media, as: YtDlpMedia
|
||||
|
||||
@doc """
|
||||
Parses the given JSON response from yt-dlp and returns a map of
|
||||
the needful media_item attributes, along with anything needed for
|
||||
@@ -24,15 +26,12 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
end
|
||||
|
||||
defp parse_media_metadata(metadata) do
|
||||
Map.merge(
|
||||
Map.from_struct(YtDlpMedia.response_to_struct(metadata)),
|
||||
%{
|
||||
media_id: metadata["id"],
|
||||
title: metadata["title"],
|
||||
original_url: metadata["original_url"],
|
||||
description: metadata["description"],
|
||||
media_filepath: metadata["filepath"],
|
||||
livestream: !!metadata["was_live"],
|
||||
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||
media_filepath: metadata["filepath"]
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp parse_subtitle_metadata(metadata) do
|
||||
|
||||
@@ -3,7 +3,7 @@ defmodule Pinchflat.Metadata.SourceMetadataStorageWorker do
|
||||
|
||||
use Oban.Worker,
|
||||
queue: :remote_metadata,
|
||||
tags: ["media_source", "source_metadata", "remote_metadata"],
|
||||
tags: ["media_source", "source_metadata", "remote_metadata", "show_in_dashboard"],
|
||||
max_attempts: 3
|
||||
|
||||
require Logger
|
||||
|
||||
@@ -27,9 +27,8 @@ defmodule Pinchflat.Podcasts.PodcastHelpers do
|
||||
limit = Keyword.get(opts, :limit, 1_000)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> order_by(desc: :upload_date)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> order_by(desc: :uploaded_at)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn media_item -> File.exists?(media_item.media_filepath) end)
|
||||
|
||||
@@ -7,7 +7,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
|
||||
import Pinchflat.Utils.XmlUtils, only: [safe: 1]
|
||||
|
||||
alias Pinchflat.Utils.DatetimeUtils
|
||||
alias Pinchflat.Podcasts.PodcastHelpers
|
||||
alias PinchflatWeb.Router.Helpers, as: Routes
|
||||
|
||||
@@ -83,7 +82,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
<title>#{safe(media_item.title)}</title>
|
||||
<link>#{safe(media_item.original_url)}</link>
|
||||
<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>
|
||||
<enclosure
|
||||
url="#{media_stream_path(url_base, media_item)}"
|
||||
@@ -133,12 +132,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
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
|
||||
Routes.podcast_path(PinchflatWeb.Endpoint, action, params)
|
||||
end
|
||||
|
||||
@@ -63,7 +63,7 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
# See `build_format_clauses` in the Media context for more.
|
||||
field :shorts_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
||||
field :livestream_behaviour, Ecto.Enum, values: ~w(include exclude only)a, default: :include
|
||||
field :preferred_resolution, Ecto.Enum, values: ~w(2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
||||
field :preferred_resolution, Ecto.Enum, values: ~w(4320p 2160p 1080p 720p 480p 360p audio)a, default: :"1080p"
|
||||
|
||||
has_many :sources, Source
|
||||
|
||||
|
||||
@@ -11,12 +11,17 @@ defmodule Pinchflat.Settings.Setting do
|
||||
:pro_enabled,
|
||||
:yt_dlp_version,
|
||||
:apprise_version,
|
||||
:apprise_server
|
||||
:apprise_server,
|
||||
:video_codec_preference,
|
||||
:audio_codec_preference,
|
||||
:youtube_api_key
|
||||
]
|
||||
|
||||
@required_fields ~w(
|
||||
onboarding
|
||||
pro_enabled
|
||||
video_codec_preference
|
||||
audio_codec_preference
|
||||
)a
|
||||
|
||||
schema "settings" do
|
||||
@@ -25,6 +30,10 @@ defmodule Pinchflat.Settings.Setting do
|
||||
field :yt_dlp_version, :string
|
||||
field :apprise_version, :string
|
||||
field :apprise_server, :string
|
||||
field :youtube_api_key, :string
|
||||
|
||||
field :video_codec_preference, :string
|
||||
field :audio_codec_preference, :string
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.SlowIndexing.MediaCollectionIndexingWorker do
|
||||
use Oban.Worker,
|
||||
queue: :media_collection_indexing,
|
||||
unique: [period: :infinity, states: [:available, :scheduled, :retryable]],
|
||||
tags: ["media_source", "media_collection_indexing"]
|
||||
tags: ["media_source", "media_collection_indexing", "show_in_dashboard"]
|
||||
|
||||
require Logger
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
|> validate_number(:retention_period_days, greater_than_or_equal_to: 0)
|
||||
# Ensures it ends with `.{{ ext }}` or `.%(ext)s` or similar (with a little wiggle room)
|
||||
|> validate_format(:output_path_template_override, MediaProfile.ext_regex(), message: "must end with .{{ ext }}")
|
||||
|> 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)
|
||||
|> unique_constraint([:collection_id, :media_profile_id, :title_filter_regex], error_key: :original_url)
|
||||
end
|
||||
@@ -128,7 +129,7 @@ defmodule Pinchflat.Sources.Source do
|
||||
@doc false
|
||||
def fast_index_frequency do
|
||||
# minutes
|
||||
15
|
||||
10
|
||||
end
|
||||
|
||||
@doc false
|
||||
@@ -141,6 +142,13 @@ defmodule Pinchflat.Sources.Source do
|
||||
~w(__meta__ __struct__ metadata tasks media_items)a
|
||||
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
|
||||
def encode(value, opts) do
|
||||
value
|
||||
|
||||
@@ -127,7 +127,7 @@ defmodule Pinchflat.Sources do
|
||||
Tasks.delete_tasks_for(source)
|
||||
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> where(^MediaQuery.for_source(source))
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn media_item ->
|
||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
|
||||
@@ -12,20 +12,26 @@ defmodule Pinchflat.Sources.SourcesQuery do
|
||||
|
||||
alias Pinchflat.Sources.Source
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to a certain record
|
||||
# - join_* - for joining on a certain record
|
||||
# - with_* - for filtering based on full, concrete attributes
|
||||
# - matching_* - for filtering based on partial attributes (e.g. LIKE, regex, full-text search)
|
||||
#
|
||||
# Suffixes:
|
||||
# - _for - the arg passed is an association record
|
||||
# This allows the module to be aliased and query methods to be used
|
||||
# all in one go
|
||||
# usage: use Pinchflat.Sources.SourcesQuery
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias unquote(__MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
def new do
|
||||
Source
|
||||
end
|
||||
|
||||
def for_media_profile(query, media_profile) do
|
||||
where(query, [s], s.media_profile_id == ^media_profile.id)
|
||||
def for_media_profile(media_profile_id) when is_integer(media_profile_id) do
|
||||
dynamic([s], s.media_profile_id == ^media_profile_id)
|
||||
end
|
||||
|
||||
def for_media_profile(media_profile) do
|
||||
dynamic([s], s.media_profile_id == ^media_profile.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule Pinchflat.Tasks.TasksQuery do
|
||||
@moduledoc """
|
||||
Query helpers for the Tasks context.
|
||||
|
||||
These methods are made to be one-ish liners used
|
||||
to compose queries. Each method should strive to do
|
||||
_one_ thing. These don't need to be tested as
|
||||
they are just building blocks for other functionality
|
||||
which, itself, will be tested.
|
||||
"""
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Pinchflat.Tasks.Task
|
||||
|
||||
# This allows the module to be aliased and query methods to be used
|
||||
# all in one go
|
||||
# usage: use Pinchflat.Tasks.TasksQuery
|
||||
defmacro __using__(_opts) do
|
||||
quote do
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias unquote(__MODULE__)
|
||||
end
|
||||
end
|
||||
|
||||
def new do
|
||||
Task
|
||||
end
|
||||
|
||||
def join_job(query) do
|
||||
query
|
||||
|> join(:left, [t], j in assoc(t, :job))
|
||||
|> preload([t, j], job: j)
|
||||
end
|
||||
|
||||
def in_state(states) when is_list(states) do
|
||||
dynamic([t, j], j.state in ^states)
|
||||
end
|
||||
|
||||
def in_state(state), do: in_state([state])
|
||||
|
||||
def has_tag(tag) do
|
||||
dynamic([t, j], ^tag in j.tags)
|
||||
end
|
||||
end
|
||||
@@ -24,11 +24,12 @@ defmodule Pinchflat.Utils.CliUtils do
|
||||
def wrap_cmd(command, args, passthrough_opts \\ [], opts \\ []) do
|
||||
wrapper_command = Path.join(:code.priv_dir(:pinchflat), "cmd_wrapper.sh")
|
||||
actual_command = [command] ++ args
|
||||
command_opts = set_command_opts() ++ passthrough_opts
|
||||
logging_arg_override = Keyword.get(opts, :logging_arg_override, Enum.join(args, " "))
|
||||
|
||||
Logger.info("[command_wrapper]: #{command} called with: #{logging_arg_override}")
|
||||
|
||||
{output, status} = System.cmd(wrapper_command, actual_command, passthrough_opts)
|
||||
{output, status} = System.cmd(wrapper_command, actual_command, command_opts)
|
||||
log_cmd_result(command, logging_arg_override, status, output)
|
||||
|
||||
{output, status}
|
||||
@@ -81,4 +82,13 @@ defmodule Pinchflat.Utils.CliUtils do
|
||||
|
||||
Logger.log(log_level, log_message)
|
||||
end
|
||||
|
||||
defp set_command_opts do
|
||||
# This resolves an issue where yt-dlp would attempt to write to a read-only directory
|
||||
# if you scanned a new video with `--windows-filenames` enabled. Hopefully can be removed
|
||||
# in the future.
|
||||
[
|
||||
cd: Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -13,4 +13,27 @@ defmodule Pinchflat.Utils.NumberUtils do
|
||||
|> max(minimum)
|
||||
|> min(maximum)
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:uploaded_at,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
@@ -21,7 +21,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:uploaded_at,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
@@ -46,6 +46,20 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
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 """
|
||||
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.
|
||||
"""
|
||||
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
|
||||
|
||||
@doc """
|
||||
@@ -90,7 +104,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
livestream: !!response["was_live"],
|
||||
duration_seconds: response["duration"] && round(response["duration"]),
|
||||
short_form_content: response["webpage_url"] && short_form_content?(response),
|
||||
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
|
||||
uploaded_at: response["upload_date"] && parse_uploaded_at(response)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -110,6 +124,18 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
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
|
||||
# This approach lets us mock the command for testing
|
||||
Application.get_env(:pinchflat, :yt_dlp_runner)
|
||||
|
||||
@@ -6,25 +6,32 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
Takes a list of tabs and renders them in a tabbed layout.
|
||||
"""
|
||||
slot :tab, required: true do
|
||||
attr :id, :string, required: true
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
slot :tab_append, required: false
|
||||
|
||||
def tabbed_layout(assigns) do
|
||||
assigns = Map.put(assigns, :first_tab_id, hd(assigns.tab).id)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
x-data="{ openTab: getTabIndex(0), activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||
@hashchange.window="openTab = getTabIndex(openTab)"
|
||||
x-data={"{
|
||||
openTab: getTabFromHash('#{@first_tab_id}', '#{@first_tab_id}'),
|
||||
activeClasses: 'text-meta-5 border-meta-5',
|
||||
inactiveClasses: 'border-transparent'
|
||||
}"}
|
||||
@hashchange.window={"openTab = getTabFromHash(openTab, '#{@first_tab_id}')"}
|
||||
class="w-full"
|
||||
>
|
||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||
<div class="flex flex-wrap gap-5 sm:gap-10">
|
||||
<a
|
||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||
:for={tab <- @tab}
|
||||
href="#"
|
||||
@click.prevent={"openTab = setTabIndex(#{idx})"}
|
||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||
@click.prevent={"openTab = setTabByName('#{tab.id}')"}
|
||||
x-bind:class={"openTab === '#{tab.id}' ? activeClasses : inactiveClasses"}
|
||||
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||
>
|
||||
<span class="text-xl"><%= tab.title %></span>
|
||||
@@ -35,7 +42,7 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-4 min-h-60">
|
||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||
<div :for={tab <- @tab} x-show={"openTab === '#{tab.id}'"} class="font-medium leading-relaxed">
|
||||
<%= render_slot(tab) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
||||
@moduledoc false
|
||||
use Phoenix.Component
|
||||
|
||||
alias PinchflatWeb.CoreComponents
|
||||
import PinchflatWeb.CoreComponents
|
||||
import PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
@doc """
|
||||
Renders a table component with the given rows and columns.
|
||||
@@ -36,7 +37,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={row <- @rows} class="border-b border-boxdark hover:border-strokedark">
|
||||
<tr :for={row <- @rows} class="hover:bg-strokedark">
|
||||
<td
|
||||
:for={col <- @col}
|
||||
class={[
|
||||
@@ -76,11 +77,13 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
||||
phx-click={@page_number != 1 && "page_change"}
|
||||
phx-value-direction="dec"
|
||||
>
|
||||
<CoreComponents.icon name="hero-chevron-left" />
|
||||
<.icon name="hero-chevron-left" />
|
||||
</span>
|
||||
</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>
|
||||
<span
|
||||
@@ -92,7 +95,7 @@ defmodule PinchflatWeb.CustomComponents.TableComponents do
|
||||
phx-click={@page_number != @total_pages && "page_change"}
|
||||
phx-value-direction="inc"
|
||||
>
|
||||
<CoreComponents.icon name="hero-chevron-right" />
|
||||
<.icon name="hero-chevron-right" />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -94,4 +94,28 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
|
||||
<time><%= Calendar.strftime(Timex.Timezone.convert(@datetime, @timezone), @format) %></time>
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -68,7 +68,7 @@ defmodule PinchflatWeb.Layouts do
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ul x-bind:class="selected ? 'block' :'hidden'">
|
||||
<ul x-cloak x-show="selected">
|
||||
<li :for={menu <- @submenu} class="text-bodydark2">
|
||||
<.sidebar_link icon={menu[:icon]} text={menu[:text]} href={menu[:href]} target={menu[:target]} class="pl-10" />
|
||||
</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 items-center gap-2 sm:gap-4 lg:hidden w-2/6">
|
||||
<section class="pr-1">
|
||||
@@ -22,7 +22,7 @@
|
||||
type="text"
|
||||
name="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"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
onboarding: #{Settings.get!(:onboarding)}
|
||||
}"}
|
||||
class="dark text-bodydark bg-boxdark-2"
|
||||
data-socket-path={Path.join(Application.get_env(:pinchflat, :base_route_path), "/live")}
|
||||
>
|
||||
<%= @inner_content %>
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
|> put_resp_header("accept-ranges", "bytes")
|
||||
|> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}")
|
||||
|> 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)
|
||||
|
||||
{:error, :invalid_range} ->
|
||||
@@ -92,8 +93,10 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("content-length", to_string(file_size))
|
||||
|> 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)
|
||||
end
|
||||
else
|
||||
|
||||
@@ -18,7 +18,7 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
||||
def media_type(media_item) do
|
||||
case Path.extname(media_item.media_filepath) do
|
||||
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
|
||||
ext when ext in [".mp3", ".m4a"] -> :audio
|
||||
ext when ext in [".mp3", ".m4a", ".opus"] -> :audio
|
||||
_ -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<.actions_dropdown media_item={@media_item} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Media">
|
||||
<:tab title="Media" id="media">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<%= if media_file_exists?(@media_item) do %>
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 xl:gap-6 mt-6">
|
||||
@@ -31,11 +31,19 @@
|
||||
<.media_preview media_item={@media_item} />
|
||||
</div>
|
||||
<aside class="mt-4 xl:mt-0">
|
||||
<div>Uploaded: <%= @media_item.upload_date %></div>
|
||||
<div :if={URI.parse(@media_item.original_url).scheme =~ "http"}>
|
||||
<div>Uploaded: <%= DateTime.to_date(@media_item.uploaded_at) %></div>
|
||||
<div>
|
||||
<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 class="mt-4 text-bodydark">
|
||||
<div :if={@media_item.description} class="mt-4 text-bodydark">
|
||||
<.break_on_newline text={@media_item.description} />
|
||||
</div>
|
||||
</aside>
|
||||
@@ -52,7 +60,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Tasks">
|
||||
<:tab title="Tasks" id="tasks">
|
||||
<%= if match?([_|_], @media_item.tasks) do %>
|
||||
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
|
||||
<:col :let={task} label="Worker">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Sources.SourcesQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Profiles
|
||||
alias Pinchflat.Sources.SourcesQuery
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
|
||||
def index(conn, _params) do
|
||||
@@ -43,7 +41,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
|
||||
|
||||
sources =
|
||||
SourcesQuery.new()
|
||||
|> SourcesQuery.for_media_profile(media_profile)
|
||||
|> where(^SourcesQuery.for_media_profile(media_profile))
|
||||
|> order_by(asc: :custom_name)
|
||||
|> Repo.all()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
|
||||
def friendly_resolution_options do
|
||||
[
|
||||
{"8k", "4320p"},
|
||||
{"4k", "2160p"},
|
||||
{"1080p", "1080p"},
|
||||
{"720p", "720p"},
|
||||
|
||||
@@ -23,9 +23,8 @@
|
||||
<:col :let={media_profile} label="Preferred Resolution">
|
||||
<%= media_profile.preferred_resolution %>
|
||||
</:col>
|
||||
<:col :let={media_profile} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<:col :let={media_profile} label="" class="flex justify-end">
|
||||
<.icon_link href={~p"/media_profiles/#{media_profile.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@
|
||||
options={friendly_format_type_options()}
|
||||
type="select"
|
||||
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]))"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
<.actions_dropdown media_profile={@media_profile} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Media Profile">
|
||||
<:tab title="Media Profile" id="media-profile">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Sources">
|
||||
<:tab title="Sources" id="sources">
|
||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
@@ -40,9 +40,8 @@
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<:col :let={source} label="" class="flex justify-end">
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||
</:col>
|
||||
</.table>
|
||||
</:tab>
|
||||
|
||||
@@ -20,14 +20,14 @@ defmodule PinchflatWeb.Pages.PageController do
|
||||
end
|
||||
|
||||
defp render_home_page(conn) do
|
||||
downloaded_media_items = where(MediaQuery.new(), ^MediaQuery.downloaded())
|
||||
|
||||
conn
|
||||
|> render(:home,
|
||||
media_profile_count: Repo.aggregate(MediaProfile, :count, :id),
|
||||
source_count: Repo.aggregate(Source, :count, :id),
|
||||
media_item_count:
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_media_downloaded_at()
|
||||
|> Repo.aggregate(:count, :id)
|
||||
media_item_size: Repo.aggregate(downloaded_media_items, :sum, :media_size_bytes),
|
||||
media_item_count: Repo.aggregate(downloaded_media_items, :count, :id)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
defmodule PinchflatWeb.Pages.PageHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
||||
alias Pinchflat.Utils.NumberUtils
|
||||
alias PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
@limit 10
|
||||
@limit 5
|
||||
|
||||
def render(%{records: []} = assigns) do
|
||||
~H"""
|
||||
@@ -22,7 +22,9 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
||||
<div>
|
||||
<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" />
|
||||
<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>
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.table rows={@records} table_class="text-white">
|
||||
@@ -32,7 +34,7 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Upload Date">
|
||||
<%= media_item.upload_date %>
|
||||
<%= DateTime.to_date(media_item.uploaded_at) %>
|
||||
</:col>
|
||||
<:col :let={media_item} label="Indexed At">
|
||||
<%= format_datetime(media_item.inserted_at) %>
|
||||
@@ -97,7 +99,8 @@ defmodule Pinchflat.Pages.HistoryTableLive do
|
||||
|
||||
defp generate_base_query do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.where_pending_or_downloaded()
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.downloaded() or ^MediaQuery.pending()))
|
||||
|> order_by(desc: :id)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,33 +1,54 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="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">
|
||||
<span class="text-md font-medium">Media Profile(s)</span>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<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="flex flex-col items-center py-2">
|
||||
<span class="text-md font-medium">
|
||||
Media <.pluralize count={@media_profile_count} word="Profile" />
|
||||
</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @media_profile_count %>
|
||||
<.localized_number number={@media_profile_count} />
|
||||
</h4>
|
||||
</a>
|
||||
</div>
|
||||
<div class="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">
|
||||
<span class="text-md font-medium">Source(s)</span>
|
||||
<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="flex flex-col items-center py-2">
|
||||
<span class="text-md font-medium">
|
||||
<.pluralize count={@source_count} word="Source" />
|
||||
</span>
|
||||
<h4 class="text-title-md font-bold text-white">
|
||||
<%= @source_count %>
|
||||
<.localized_number number={@source_count} />
|
||||
</h4>
|
||||
</a>
|
||||
</div>
|
||||
<div class="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="text-md font-medium">Downloaded Media</span>
|
||||
<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">
|
||||
<.pluralize count={@media_item_count} word="Download" />
|
||||
</span>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
||||
<span class="text-2xl font-medium mb-4">History</span>
|
||||
<span class="text-2xl font-medium mb-4">Media History</span>
|
||||
<section class="mt-6">
|
||||
<%= live_render(@conn, Pinchflat.Pages.HistoryTableLive) %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm border shadow-default border-strokedark bg-boxdark mt-4 p-5">
|
||||
<span class="text-2xl font-medium mb-4">Active Tasks</span>
|
||||
<section class="mt-6 min-h-80">
|
||||
<%= live_render(@conn, Pinchflat.Pages.JobTableLive) %>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
defmodule Pinchflat.Pages.JobTableLive do
|
||||
use PinchflatWeb, :live_view
|
||||
use Pinchflat.Tasks.TasksQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks.Task
|
||||
alias PinchflatWeb.CustomComponents.TextComponents
|
||||
|
||||
def render(%{tasks: []} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-4 flex items-center">
|
||||
<p>Nothing Here!</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.table rows={@tasks} table_class="text-white">
|
||||
<:col :let={task} label="Task">
|
||||
<%= worker_to_task_name(task.job.worker) %>
|
||||
</:col>
|
||||
<:col :let={task} label="Subject">
|
||||
<.subtle_link href={task_to_link(task)}>
|
||||
<%= StringUtils.truncate(task_to_record_name(task), 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={task} label="Attempt No.">
|
||||
<%= task.job.attempt %>
|
||||
</:col>
|
||||
<:col :let={task} label="Started At">
|
||||
<%= format_datetime(task.job.attempted_at) %>
|
||||
</:col>
|
||||
</.table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
PinchflatWeb.Endpoint.subscribe("job:state")
|
||||
|
||||
{:ok, assign(socket, tasks: get_tasks())}
|
||||
end
|
||||
|
||||
def handle_info(%{topic: "job:state", event: "change"}, socket) do
|
||||
{:noreply, assign(socket, tasks: get_tasks())}
|
||||
end
|
||||
|
||||
defp get_tasks do
|
||||
TasksQuery.new()
|
||||
|> TasksQuery.join_job()
|
||||
|> where(^TasksQuery.in_state("executing"))
|
||||
|> where(^TasksQuery.has_tag("show_in_dashboard"))
|
||||
|> order_by([t, j], desc: j.attempted_at)
|
||||
|> Repo.all()
|
||||
|> Repo.preload([:media_item, :source])
|
||||
end
|
||||
|
||||
defp worker_to_task_name(worker) do
|
||||
final_module_part =
|
||||
worker
|
||||
|> String.split(".")
|
||||
|> Enum.at(-1)
|
||||
|
||||
map_worker_to_task_name(final_module_part)
|
||||
end
|
||||
|
||||
defp map_worker_to_task_name("FastIndexingWorker"), do: "Fast Indexing Source"
|
||||
defp map_worker_to_task_name("MediaDownloadWorker"), do: "Downloading Media"
|
||||
defp map_worker_to_task_name("MediaCollectionIndexingWorker"), do: "Indexing Source"
|
||||
defp map_worker_to_task_name("MediaQualityUpgradeWorker"), do: "Upgrading Media Quality"
|
||||
defp map_worker_to_task_name("SourceMetadataStorageWorker"), do: "Fetching Source Metadata"
|
||||
defp map_worker_to_task_name(other), do: other <> " (Report to Devs)"
|
||||
|
||||
defp task_to_record_name(%Task{} = task) do
|
||||
case task do
|
||||
%Task{source: source} when source != nil -> source.custom_name
|
||||
%Task{media_item: mi} when mi != nil -> mi.title
|
||||
_ -> "Unknown Record"
|
||||
end
|
||||
end
|
||||
|
||||
defp task_to_link(%Task{} = task) do
|
||||
case task do
|
||||
%Task{source: source} when source != nil -> ~p"/sources/#{source.id}"
|
||||
%Task{media_item: mi} when mi != nil -> ~p"/sources/#{mi.source_id}/media/#{mi}"
|
||||
_ -> "#"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_datetime(nil), do: ""
|
||||
|
||||
defp format_datetime(datetime) do
|
||||
TextComponents.datetime_in_zone(%{datetime: datetime, format: "%Y-%m-%d %H:%M"})
|
||||
end
|
||||
end
|
||||
@@ -26,8 +26,7 @@ defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
# if the source doesn't have any usable images
|
||||
media_items =
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
|> Repo.maybe_limit(1)
|
||||
|> Repo.all()
|
||||
|
||||
|
||||
@@ -10,19 +10,13 @@
|
||||
<%= if match?([_|_], @search_results) do %>
|
||||
<.table rows={@search_results} table_class="text-black dark:text-white">
|
||||
<: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 :let={result} label="Excerpt">
|
||||
<.highlight_search_terms text={result.matching_search_term} />
|
||||
</: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>
|
||||
<% else %>
|
||||
<p class="font-bold text-lg text-center text-black dark:text-white">No results found</p>
|
||||
|
||||
@@ -14,18 +14,27 @@ defmodule PinchflatWeb.Settings.SettingHTML do
|
||||
|
||||
def apprise_server_help do
|
||||
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
|
||||
|
||||
def diagnostic_info_string do
|
||||
"""
|
||||
App Version: #{Application.spec(:pinchflat)[:vsn]}
|
||||
yt-dlp Version: #{Settings.get!(:yt_dlp_version)}
|
||||
Apprise Version: #{Settings.get!(:apprise_version)}
|
||||
System Architecture: #{to_string(:erlang.system_info(:system_architecture))}
|
||||
Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
||||
- App Version: #{Application.spec(:pinchflat)[:vsn]}
|
||||
- yt-dlp Version: #{Settings.get!(:yt_dlp_version)}
|
||||
- Apprise Version: #{Settings.get!(:apprise_version)}
|
||||
- System Architecture: #{to_string(:erlang.system_info(:system_architecture))}
|
||||
- Timezone: #{Application.get_env(:pinchflat, :timezone)}
|
||||
"""
|
||||
end
|
||||
|
||||
defp help_link_classes do
|
||||
"underline decoration-bodydark decoration-1 hover:decoration-white"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
() => copied = false
|
||||
)
|
||||
"}>
|
||||
Copy Diagnotic Info
|
||||
Copy Diagnostic Info
|
||||
<span x-show="copied" x-transition.duration.150ms><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</.button>
|
||||
<.link href={~p"/download_logs"}>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<aside>
|
||||
<h2 class="text-xl font-bold mb-2">Codec Preference</h2>
|
||||
<section class="ml-2 md:ml-4 mb-2 max-w-prose">
|
||||
<p>
|
||||
Some users may want to specify the audio codec that <code class="text-sm">yt-dlp</code>
|
||||
searches for when downloading content. For these users, you can specify one audio and video
|
||||
codec to prefer above all others.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
NOTE: this is a <em>soft</em>
|
||||
preference. If the codec you specify is not available, <code class="text-sm">yt-dlp</code>
|
||||
will download the next best available codec. This is expected and intended behavior.
|
||||
<.inline_link href="https://github.com/yt-dlp/yt-dlp#sorting-formats">See here</.inline_link>
|
||||
for more information and a list of available codecs.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -1,13 +1,24 @@
|
||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||
<.simple_form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
action={@action}
|
||||
x-data="{ advancedMode: !!JSON.parse(localStorage.getItem('advancedMode')) }"
|
||||
x-init="$watch('advancedMode', value => localStorage.setItem('advancedMode', JSON.stringify(value)))"
|
||||
>
|
||||
<.error :if={@changeset.action}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
|
||||
<h3 class="mt-2 md:mt-8 text-2xl text-black dark:text-white">
|
||||
<section>
|
||||
<section class="flex justify-between items-center mt-4">
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Notification Settings
|
||||
</h3>
|
||||
<span class="cursor-pointer hover:underline" x-on:click="advancedMode = !advancedMode">
|
||||
Editing Mode: <span x-text="advancedMode ? 'Advanced' : 'Standard'"></span>
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Settings.AppriseServerLive,
|
||||
@@ -15,5 +26,57 @@
|
||||
) %>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<h3 class="text-2xl text-black dark:text-white">
|
||||
Codec Options
|
||||
</h3>
|
||||
|
||||
<p class="text-sm mt-2 max-w-prose">
|
||||
The best available codec will be used if your preferred codecs are not found
|
||||
</p>
|
||||
|
||||
<.input
|
||||
field={f[:video_codec_preference]}
|
||||
placeholder="avc"
|
||||
type="text"
|
||||
label="Video Codec Preference"
|
||||
help="Video codec preference. Will be remuxed into an MP4 container. See below for more details"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={f[:audio_codec_preference]}
|
||||
placeholder="m4a"
|
||||
type="text"
|
||||
label="Audio Codec Preference"
|
||||
help="Audio codec preference. See below for more details"
|
||||
inputclass="font-mono text-sm mr-4"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="rounded-sm dark:bg-meta-4 p-4 md:p-6 mt-5">
|
||||
<.codec_settings_help />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.button class="mt-10 mb-4 sm:mb-8 w-full sm:w-auto" rounding="rounded-lg">Save Settings</.button>
|
||||
</.simple_form>
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
defmodule PinchflatWeb.Sources.SourceController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
use Pinchflat.Media.MediaQuery
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.Downloading.DownloadingHelpers
|
||||
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
|
||||
alias Pinchflat.Metadata.SourceMetadataStorageWorker
|
||||
|
||||
def index(conn, _params) do
|
||||
sources =
|
||||
Source
|
||||
|> order_by(asc: :custom_name)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:media_profile)
|
||||
source_query =
|
||||
from s in Source,
|
||||
as: :source,
|
||||
inner_join: mp in assoc(s, :media_profile),
|
||||
preload: [media_profile: mp],
|
||||
order_by: [asc: s.custom_name],
|
||||
select: map(s, ^Source.__schema__(:fields)),
|
||||
select_merge: %{
|
||||
downloaded_count:
|
||||
subquery(
|
||||
from m in MediaItem,
|
||||
where: m.source_id == parent_as(:source).id,
|
||||
where: ^MediaQuery.downloaded(),
|
||||
select: count(m.id)
|
||||
),
|
||||
pending_count:
|
||||
subquery(
|
||||
from m in MediaItem,
|
||||
join: s in assoc(m, :source),
|
||||
where: m.source_id == parent_as(:source).id,
|
||||
where: ^MediaQuery.pending(),
|
||||
select: count(m.id)
|
||||
)
|
||||
}
|
||||
|
||||
render(conn, :index, sources: sources)
|
||||
render(conn, :index, sources: Repo.all(source_query))
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||
def friendly_index_frequencies do
|
||||
[
|
||||
{"Only once when first created", -1},
|
||||
{"30 minutes", 30},
|
||||
{"1 Hour", 60},
|
||||
{"3 Hours", 3 * 60},
|
||||
{"6 Hours", 6 * 60},
|
||||
|
||||
@@ -1,21 +1,63 @@
|
||||
<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">
|
||||
<p>
|
||||
Indexing is the act of scanning a channel or playlist (aka: source) for new media.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
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 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 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.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
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.
|
||||
<p class="mt-4">
|
||||
<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>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
<.table rows={@sources} table_class="text-black dark:text-white">
|
||||
<:col :let={source} label="Name">
|
||||
<.subtle_link href={~p"/sources/#{source.id}"}>
|
||||
<%= source.custom_name || source.collection_name %>
|
||||
<%= StringUtils.truncate(source.custom_name || source.collection_name, 35) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="Type"><%= source.collection_type %></:col>
|
||||
<:col :let={source} label="Should Download?">
|
||||
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
|
||||
</:col>
|
||||
<:col :let={source} label="Pending"><.localized_number number={source.pending_count} /></:col>
|
||||
<:col :let={source} label="Downloaded"><.localized_number number={source.downloaded_count} /></:col>
|
||||
<:col :let={source} label="Retention">
|
||||
<%= 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 %>
|
||||
<span class="text-lg">∞</span>
|
||||
<% end %>
|
||||
@@ -35,7 +35,6 @@
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={source} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
@@ -8,7 +8,7 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
|
||||
@limit 10
|
||||
|
||||
def render(%{records: []} = assigns) do
|
||||
def render(%{total_record_count: 0} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-4 flex items-center">
|
||||
<.icon_button icon_name="hero-arrow-path" class="h-10 w-10" phx-click="reload_page" />
|
||||
@@ -20,19 +20,45 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<span class="mb-4 flex items-center">
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<span class="flex items-center">
|
||||
<.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={@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">
|
||||
<:col :let={media_item} label="Title">
|
||||
<.subtle_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"}>
|
||||
<%= StringUtils.truncate(media_item.title, 50) %>
|
||||
</.subtle_link>
|
||||
</:col>
|
||||
<:col :let={media_item} label="" class="flex place-content-evenly">
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" class="mx-1" />
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mx-1" />
|
||||
<: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">
|
||||
<.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}/edit"} icon="hero-pencil-square" class="mr-4" />
|
||||
</:col>
|
||||
</.table>
|
||||
<section class="flex justify-center mt-5">
|
||||
@@ -43,36 +69,99 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
end
|
||||
|
||||
def mount(_params, session, socket) do
|
||||
PinchflatWeb.Endpoint.subscribe("media_table")
|
||||
|
||||
page = 1
|
||||
media_state = session["media_state"]
|
||||
source = Sources.get_source!(session["source_id"])
|
||||
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
|
||||
|
||||
def handle_event("page_change", %{"direction" => direction}, %{assigns: assigns} = socket) do
|
||||
direction = if direction == "inc", do: 1, else: -1
|
||||
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)}
|
||||
end
|
||||
|
||||
def handle_event("reload_page", _params, %{assigns: assigns} = socket) do
|
||||
new_assigns = fetch_pagination_attributes(assigns.base_query, assigns.page)
|
||||
def handle_event("search_term", params, socket) do
|
||||
search_term = Map.get(params, "q", nil)
|
||||
new_assigns = fetch_pagination_attributes(socket.assigns.base_query, 1, search_term)
|
||||
|
||||
{:noreply, assign(socket, new_assigns)}
|
||||
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_pages = max(ceil(total_record_count / @limit), 1)
|
||||
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
|
||||
|
||||
defp fetch_records(base_query, page) do
|
||||
@@ -81,20 +170,41 @@ defmodule Pinchflat.Sources.MediaItemTableLive do
|
||||
base_query
|
||||
|> limit(^@limit)
|
||||
|> offset(^offset)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp generate_base_query(source, "pending") do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.where_pending_download()
|
||||
|> order_by(desc: :id)
|
||||
|> select(^select_fields())
|
||||
|> MediaQuery.require_assoc(:media_profile)
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.pending()))
|
||||
end
|
||||
|
||||
defp generate_base_query(source, "downloaded") do
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> order_by(desc: :id)
|
||||
|> select(^select_fields())
|
||||
|> where(^dynamic(^MediaQuery.for_source(source) and ^MediaQuery.downloaded()))
|
||||
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
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<.actions_dropdown source={@source} conn={@conn} />
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Source">
|
||||
<:tab title="Source" id="source">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl mt-6">Raw Attributes</h3>
|
||||
<section>
|
||||
@@ -36,21 +36,28 @@
|
||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||
</div>
|
||||
</:tab>
|
||||
<:tab title="Pending Media">
|
||||
<:tab title="Pending" id="pending">
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Sources.MediaItemTableLive,
|
||||
session: %{"source_id" => @source.id, "media_state" => "pending"}
|
||||
) %>
|
||||
</:tab>
|
||||
<:tab title="Downloaded Media">
|
||||
<:tab title="Downloaded" id="downloaded">
|
||||
<%= live_render(
|
||||
@conn,
|
||||
Pinchflat.Sources.MediaItemTableLive,
|
||||
session: %{"source_id" => @source.id, "media_state" => "downloaded"}
|
||||
) %>
|
||||
</:tab>
|
||||
<:tab title="Pending 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 %>
|
||||
<.table rows={@pending_tasks} table_class="text-black dark:text-white">
|
||||
<:col :let={task} label="Worker">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
General Options
|
||||
</h3>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
label="Index Frequency"
|
||||
x-bind:disabled="fastIndexingEnabled == true"
|
||||
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")}>
|
||||
@@ -58,7 +58,7 @@
|
||||
type="toggle"
|
||||
label="Use Fast Indexing"
|
||||
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="
|
||||
// `enabled` is the data attribute that the toggle uses internally
|
||||
fastIndexingEnabled = enabled
|
||||
@@ -95,7 +95,8 @@
|
||||
type="number"
|
||||
label="Retention Period (days)"
|
||||
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">
|
||||
|
||||
@@ -19,6 +19,11 @@ defmodule PinchflatWeb.Telemetry do
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def job_state_change_broadcast(_event, _measure, _meta, _config) do
|
||||
PinchflatWeb.Endpoint.broadcast("job:state", "change", nil)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "2024.5.15",
|
||||
version: "2024.6.10",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
@@ -48,6 +48,7 @@ defmodule Pinchflat.MixProject do
|
||||
[
|
||||
{:phoenix, "~> 1.7.10"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:ecto, "~> 3.11.2"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
{:ecto_sqlite3, ">= 0.0.0"},
|
||||
{:ecto_sqlite3_extras, "~> 1.2.0"},
|
||||
@@ -92,7 +93,15 @@ defmodule Pinchflat.MixProject do
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||
"assets.build": ["tailwind default", "esbuild default"],
|
||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
|
||||
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"],
|
||||
"ecto.migrate": [
|
||||
"ecto.migrate",
|
||||
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||
],
|
||||
"ecto.rollback": [
|
||||
"ecto.rollback",
|
||||
~s(cmd [ -z "$MIX_ENV" ] && yarn run create-erd || echo "No ERD generated")
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.1.2", "3eb5be824c7888dadf9781018e1a5f1d3d1113b333c50bce90fb1b83df1015f2", [:mix], [], "hexpm", "7494272040f847637bbdb01bcdf4b871e82daf09b813e7d3cb3b84f112c6f2f8"},
|
||||
"ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"},
|
||||
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.15.1", "40f2fbd9e246455f8c42e7e0a77009ef806caa1b3ce6f717b2a0a80e8432fcfd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.19", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "28b16e177123c688948357176662bf9ff9084daddf950ef5b6baf3ee93707064"},
|
||||
"ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"},
|
||||
|
||||
+6
-2
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"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": {
|
||||
"prettier": "3.2.4"
|
||||
"prettier": "3.2.4",
|
||||
"sqleton": "^2.2.0"
|
||||
},
|
||||
"license": "<See the LICENSE file>"
|
||||
"scripts": {
|
||||
"create-erd": "sqleton -o priv/repo/erd.png priv/repo/pinchflat_dev.db"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddCodecPreferencesToSettings do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:settings) do
|
||||
add :video_codec_preference, {:array, :string}, default: []
|
||||
add :audio_codec_preference, {:array, :string}, default: []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -13,6 +13,19 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "ensure_tmpfile_directory" do
|
||||
test "creates the tmpfile directory if it doesn't exist" do
|
||||
tmpfile_dir = Application.get_env(:pinchflat, :tmpfile_directory)
|
||||
File.rm_rf!(tmpfile_dir)
|
||||
|
||||
refute File.exists?(tmpfile_dir)
|
||||
|
||||
PreJobStartupTasks.init(%{})
|
||||
|
||||
assert File.exists?(tmpfile_dir)
|
||||
end
|
||||
end
|
||||
|
||||
describe "reset_executing_jobs" do
|
||||
test "resets executing jobs" do
|
||||
job = job_fixture()
|
||||
@@ -78,6 +91,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||
|
||||
describe "apply_default_settings" do
|
||||
test "sets yt_dlp version" do
|
||||
File.rm_rf!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||
Settings.set(yt_dlp_version: nil)
|
||||
|
||||
refute Settings.get!(:yt_dlp_version)
|
||||
@@ -88,6 +102,7 @@ defmodule Pinchflat.Boot.PreJobStartupTasksTest do
|
||||
end
|
||||
|
||||
test "sets apprise version" do
|
||||
File.rm_rf!(Application.get_env(:pinchflat, :tmpfile_directory))
|
||||
Settings.set(apprise_version: nil)
|
||||
|
||||
refute Settings.get!(:apprise_version)
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Profiles
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Utils.FilesystemUtils
|
||||
alias Pinchflat.Downloading.DownloadOptionBuilder
|
||||
|
||||
@@ -244,8 +245,8 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
|
||||
describe "build/1 when testing quality options" do
|
||||
test "it includes quality options" do
|
||||
resolutions = ["360", "480", "720", "1080", "2160"]
|
||||
test "includes quality options" do
|
||||
resolutions = ["360", "480", "720", "1080", "2160", "4320"]
|
||||
|
||||
Enum.each(resolutions, fn resolution ->
|
||||
resolution_atom = String.to_existing_atom(resolution <> "p")
|
||||
@@ -255,21 +256,33 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
media_item = Repo.preload(media_item_fixture(source_id: source.id), source: :media_profile)
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:format_sort, "res:#{resolution},+codec:avc:m4a"} in res
|
||||
assert {:remux_video, "mp4"} in res
|
||||
end)
|
||||
end
|
||||
|
||||
test "it includes quality options for audio only", %{media_item: media_item} do
|
||||
test "includes quality options for audio only", %{media_item: media_item} do
|
||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :audio})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert :extract_audio in res
|
||||
assert {:format, "bestaudio[ext=m4a]/bestaudio[ext=mp3]/bestaudio/best[ext=m4a]/best[ext=mp3]/best"} in res
|
||||
assert {:format_sort, "+acodec:m4a"} in res
|
||||
|
||||
refute {:remux_video, "mp4"} in res
|
||||
end
|
||||
|
||||
test "includes custom quality options if specified", %{media_item: media_item} do
|
||||
Settings.set(video_codec_preference: "av01")
|
||||
Settings.set(audio_codec_preference: "aac")
|
||||
|
||||
media_item = update_media_profile_attribute(media_item, %{preferred_resolution: :"1080p"})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:format_sort, "res:1080,+codec:av01:aac"} in res
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/1 when testing sponsorblock options" do
|
||||
|
||||
@@ -114,7 +114,7 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
||||
describe "kickoff_redownload_for_existing_media/1" do
|
||||
test "enqueues a download job for each downloaded media item" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(source_id: source.id, media_downloaded_at: now())
|
||||
media_item = media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4")
|
||||
|
||||
assert [{:ok, _}] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
||||
|
||||
@@ -124,14 +124,14 @@ defmodule Pinchflat.Downloading.DownloadingHelpersTest do
|
||||
test "doesn't enqueue jobs for media that should be ignored" do
|
||||
source = source_fixture()
|
||||
other_source = source_fixture()
|
||||
_not_downloaded = media_item_fixture(source_id: source.id, media_downloaded_at: nil)
|
||||
_other_source = media_item_fixture(source_id: other_source.id, media_downloaded_at: now())
|
||||
_not_downloaded = media_item_fixture(source_id: source.id, media_filepath: nil)
|
||||
_other_source = media_item_fixture(source_id: other_source.id, media_filepath: "some/filepath.mp4")
|
||||
|
||||
_download_prevented =
|
||||
media_item_fixture(source_id: source.id, media_downloaded_at: now(), prevent_download: true)
|
||||
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", prevent_download: true)
|
||||
|
||||
_culled =
|
||||
media_item_fixture(source_id: source.id, media_downloaded_at: now(), culled_at: now())
|
||||
media_item_fixture(source_id: source.id, media_filepath: "some/filepath.mp4", culled_at: now())
|
||||
|
||||
assert [] = DownloadingHelpers.kickoff_redownload_for_existing_media(source)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Pinchflat.Downloading.MediaDownloadWorkerTest do
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
|
||||
setup do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
stub(UserScriptRunnerMock, :run, fn _event_type, _data -> :ok end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
[:metadata, source: :media_profile]
|
||||
)
|
||||
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts ->
|
||||
{:ok, ""}
|
||||
end)
|
||||
stub(HTTPClientMock, :get, fn _url, _headers, _opts -> {:ok, ""} end)
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> {:ok, ""} end)
|
||||
|
||||
{:ok, %{media_item: media_item}}
|
||||
end
|
||||
@@ -44,7 +43,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
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.thumbnail_filepath =~ "media_items/#{media_item.id}/maxresdefault.jpg"
|
||||
assert updated_media_item.metadata.thumbnail_filepath =~ "media_items/#{media_item.id}/thumbnail.jpg"
|
||||
end
|
||||
|
||||
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_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(6, :days),
|
||||
uploaded_at: now_minus(6, :days),
|
||||
media_downloaded_at: now_minus(5, :days)
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ defmodule Pinchflat.Downloading.MediaQualityUpgradeWorkerTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(6, :days),
|
||||
uploaded_at: now_minus(6, :days),
|
||||
media_downloaded_at: now_minus(1, :day)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
import Pinchflat.ProfilesFixtures
|
||||
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Settings
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.FastIndexingHelpers
|
||||
|
||||
describe "kickoff_download_tasks_from_youtube_rss_feed/1" do
|
||||
setup do
|
||||
stub(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, media_attributes_return_fixture()}
|
||||
@@ -19,6 +19,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
{:ok, [source: source_fixture()]}
|
||||
end
|
||||
|
||||
describe "kickoff_download_tasks_from_youtube_rss_feed/1" 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)
|
||||
|
||||
@@ -107,4 +108,49 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
|
||||
assert [] = FastIndexingHelpers.kickoff_download_tasks_from_youtube_rss_feed(source)
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
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
|
||||
source = source_fixture(collection_type: :channel, collection_id: "channel_id")
|
||||
|
||||
@@ -21,7 +27,7 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "calls the expected URL for playlist sources" do
|
||||
@@ -33,13 +39,13 @@ defmodule Pinchflat.FastIndexing.YoutubeRssTest do
|
||||
{:ok, ""}
|
||||
end)
|
||||
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, _} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
test "returns an error if the HTTP request fails", %{source: source} do
|
||||
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
|
||||
|
||||
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>"}
|
||||
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
|
||||
|
||||
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>"}
|
||||
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
|
||||
|
||||
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>"}
|
||||
end)
|
||||
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
assert {:ok, ["test_1"]} = YoutubeRss.get_recent_media_ids(source)
|
||||
end
|
||||
|
||||
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>"}
|
||||
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
|
||||
|
||||
+190
-174
@@ -34,150 +34,6 @@ defmodule Pinchflat.MediaTest do
|
||||
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
|
||||
test "it returns all media_items" do
|
||||
media_item = media_item_fixture()
|
||||
@@ -289,7 +145,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(6, :days),
|
||||
uploaded_at: now_minus(6, :days),
|
||||
media_downloaded_at: now_minus(5, :days)
|
||||
})
|
||||
|
||||
@@ -300,7 +156,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(20, :days),
|
||||
uploaded_at: now_minus(20, :days),
|
||||
media_downloaded_at: now_minus(19, :days)
|
||||
})
|
||||
|
||||
@@ -311,7 +167,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(5, :days),
|
||||
uploaded_at: now_minus(5, :days),
|
||||
media_downloaded_at: nil
|
||||
})
|
||||
|
||||
@@ -322,7 +178,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(5, :days),
|
||||
uploaded_at: now_minus(5, :days),
|
||||
media_downloaded_at: now(),
|
||||
prevent_download: true
|
||||
})
|
||||
@@ -334,7 +190,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(5, :days),
|
||||
uploaded_at: now_minus(5, :days),
|
||||
media_downloaded_at: now(),
|
||||
culled_at: now()
|
||||
})
|
||||
@@ -346,7 +202,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(3, :days),
|
||||
uploaded_at: now_minus(3, :days),
|
||||
media_downloaded_at: now_minus(3, :days)
|
||||
})
|
||||
|
||||
@@ -357,7 +213,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(5, :days),
|
||||
uploaded_at: now_minus(5, :days),
|
||||
media_downloaded_at: now(),
|
||||
media_redownloaded_at: now()
|
||||
})
|
||||
@@ -365,12 +221,12 @@ defmodule Pinchflat.MediaTest do
|
||||
assert Media.list_redownloadable_media_items() == []
|
||||
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_fixture(%{
|
||||
source_id: source.id,
|
||||
media_downloaded_at: now(),
|
||||
upload_date: now_minus(20, :days)
|
||||
uploaded_at: now_minus(20, :days)
|
||||
})
|
||||
|
||||
assert Media.list_redownloadable_media_items() == []
|
||||
@@ -381,7 +237,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
media_downloaded_at: now(),
|
||||
upload_date: now_minus(2, :days)
|
||||
uploaded_at: now_minus(2, :days)
|
||||
})
|
||||
|
||||
assert Media.list_redownloadable_media_items() == []
|
||||
@@ -394,7 +250,7 @@ defmodule Pinchflat.MediaTest do
|
||||
_media_item =
|
||||
media_item_fixture(%{
|
||||
source_id: source.id,
|
||||
upload_date: now_minus(6, :days),
|
||||
uploaded_at: now_minus(6, :days),
|
||||
media_downloaded_at: now_minus(5, :days)
|
||||
})
|
||||
|
||||
@@ -405,7 +261,9 @@ defmodule Pinchflat.MediaTest do
|
||||
describe "list_pending_media_items_for/1" do
|
||||
test "it returns pending without a filepath for a given source" do
|
||||
source = source_fixture()
|
||||
other_source = source_fixture()
|
||||
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
|
||||
_other_media_item = media_item_fixture(%{source_id: other_source.id, media_filepath: nil})
|
||||
|
||||
assert Media.list_pending_media_items_for(source) == [media_item]
|
||||
end
|
||||
@@ -550,9 +408,9 @@ defmodule Pinchflat.MediaTest do
|
||||
source = source_fixture(%{download_cutoff_date: now_minus(1, :day)})
|
||||
|
||||
_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]
|
||||
end
|
||||
@@ -561,9 +419,9 @@ defmodule Pinchflat.MediaTest do
|
||||
source = source_fixture(%{download_cutoff_date: nil})
|
||||
|
||||
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]
|
||||
end
|
||||
@@ -628,21 +486,21 @@ defmodule Pinchflat.MediaTest 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)})
|
||||
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)
|
||||
end
|
||||
|
||||
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)})
|
||||
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)
|
||||
end
|
||||
|
||||
test "returns true if there is no cutoff date" do
|
||||
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)
|
||||
end
|
||||
@@ -725,6 +583,17 @@ defmodule Pinchflat.MediaTest do
|
||||
test "returns an empty list when the search term is nil" do
|
||||
assert [] = Media.search(nil)
|
||||
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
|
||||
|
||||
describe "get_media_item!/1" do
|
||||
@@ -742,7 +611,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
||||
source_id: source_fixture().id,
|
||||
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)
|
||||
@@ -759,7 +628,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
||||
source_id: source_fixture().id,
|
||||
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)
|
||||
@@ -774,7 +643,7 @@ defmodule Pinchflat.MediaTest do
|
||||
media_filepath: "/video/#{Faker.File.file_name(:video)}",
|
||||
source_id: source_fixture().id,
|
||||
original_url: "https://www.youtube.com/channel/#{Faker.String.base64(12)}",
|
||||
upload_date: Date.utc_today(),
|
||||
uploaded_at: now(),
|
||||
uuid: "some-uuid"
|
||||
}
|
||||
|
||||
@@ -871,14 +740,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,14 +772,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -991,14 +858,13 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
update_attrs = %{
|
||||
metadata: %{
|
||||
metadata_filepath: MetadataFileHelpers.compress_and_store_metadata_for(media_item, %{}),
|
||||
thumbnail_filepath:
|
||||
MetadataFileHelpers.download_and_store_thumbnail_for(media_item, render_parsed_metadata(:media_metadata))
|
||||
thumbnail_filepath: MetadataFileHelpers.download_and_store_thumbnail_for(media_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,4 +905,154 @@ defmodule Pinchflat.MediaTest do
|
||||
assert %Ecto.Changeset{} = Media.change_media_item(media_item)
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user