Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21eaeb8b60 | |||
| 2b348607bf | |||
| 2affcd8616 | |||
| cab4c339e5 | |||
| 103541cd20 | |||
| 8cba501bff | |||
| 6a02f217e3 | |||
| c3f49184b5 | |||
| 3837a0dedd |
@@ -1,54 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
type: choice
|
||||
description: 'Docker Image Tag'
|
||||
required: true
|
||||
default: 'dev'
|
||||
options:
|
||||
- 'dev'
|
||||
- 'latest'
|
||||
platforms:
|
||||
type: choice
|
||||
description: 'Build Platforms'
|
||||
required: true
|
||||
default: 'linux/amd64,linux/arm64'
|
||||
options:
|
||||
- 'linux/amd64,linux/arm64'
|
||||
- 'linux/amd64'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: ${{ github.event.inputs.platforms }}
|
||||
push: true
|
||||
file: ./selfhosted.Dockerfile
|
||||
tags: |
|
||||
keglin/pinchflat:${{ github.event.inputs.image_tag }}
|
||||
ghcr.io/${{ github.repository_owner }}/pinchflat:${{ github.event.inputs.image_tag }}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platforms:
|
||||
type: choice
|
||||
description: 'Build Platforms'
|
||||
required: true
|
||||
default: 'linux/amd64'
|
||||
options:
|
||||
- 'linux/amd64'
|
||||
- 'linux/amd64,linux/arm64'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build_and_push_docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
dev_arch: 'linux/amd64'
|
||||
release_arch: 'linux/amd64,linux/arm64'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/keglin/pinchflat
|
||||
ghcr.io/${{ github.repository }}
|
||||
# All non-release actions will be tagged as `dev` (ie: push, workflow_dispatch)
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
type=raw,value=dev,enable=${{ github.event_name != 'release' }}
|
||||
flavor: |
|
||||
latest=auto
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./selfhosted.Dockerfile
|
||||
# If the event is a release, use the release_arch, otherwise use the
|
||||
# platforms input if present, falling back to dev_arch
|
||||
platforms: ${{ github.event_name == 'release' && env.release_arch || (github.event.inputs.platforms || env.dev_arch) }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -65,6 +65,8 @@ Simply search for Pinchflat in the Community Apps store!
|
||||
|
||||
### Portainer
|
||||
|
||||
Important: See the note below about storing config on a network file share. It's preferred to store the config on a local disk if at all possible.
|
||||
|
||||
Docker Compose file:
|
||||
|
||||
```yaml
|
||||
@@ -104,6 +106,14 @@ You _must_ ensure the host directories you've mounted are writable by the user r
|
||||
|
||||
It's recommended to not run the container as root. Doing so can create permission issues if other apps need to work with the downloaded media. If you need to run any command as root, you can run `su` from the container's shell as there is no password set for the root user.
|
||||
|
||||
### Advanced: storing Pinchflat config directory on a network share
|
||||
|
||||
README: This is currently in the testing phase and not a recommended option (yet). The implications of changing this setting isn't clear and this could, conceivably, result in data loss. Only change this setting if you know what you're doing, why this is important, and are okay with possible data loss or DB corruption. This may become the default in the future once it's been tested more thoroughly.
|
||||
|
||||
As pointed out in [#137](https://github.com/kieraneglin/pinchflat/issues/137), SQLite doesn't like being run in WAL mode on network shares. If you're running Pinchflat on a network share, you can disable WAL mode by setting the `JOURNAL_MODE` environment variable to `delete`. This will make Pinchflat run in rollback journal mode which is less performant but should work on network shares.
|
||||
|
||||
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.
|
||||
|
||||
## EFF donations
|
||||
|
||||
A portion of all donations to Pinchflat will be donated to the [Electronic Frontier Foundation](https://www.eff.org/). The EFF defends your online liberties and [backed](https://github.com/github/dmca/blob/9a85e0f021f7967af80e186b890776a50443f06c/2020/11/2020-11-16-RIAA-reversal-effletter.pdf) `youtube-dl` when Google took them down. [See here](https://github.com/kieraneglin/pinchflat/wiki/EFF-Donation-Receipts) for a list of donation receipts.
|
||||
|
||||
@@ -25,6 +25,10 @@ config :pinchflat,
|
||||
expose_feed_endpoints: false,
|
||||
file_watcher_poll_interval: 1000
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
journal_mode: :wal,
|
||||
pool_size: 5
|
||||
|
||||
# Configures the endpoint
|
||||
config :pinchflat, PinchflatWeb.Endpoint,
|
||||
url: [host: "localhost", port: 8945],
|
||||
|
||||
+4
-10
@@ -50,16 +50,10 @@ if config_env() == :prod do
|
||||
# For running PF as a podcast host on self-hosted environments
|
||||
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
|
||||
|
||||
# We want to force _some_ level of useful logging in production
|
||||
acceptable_log_levels = ~w(debug info)a
|
||||
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||
# For testing alternate journal modes (see issue #137)
|
||||
journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
|
||||
|
||||
if log_level in acceptable_log_levels do
|
||||
config :logger, level: log_level
|
||||
else
|
||||
Logger.error("Invalid log level: #{log_level}. Defaulting to info.")
|
||||
config :logger, level: :info
|
||||
end
|
||||
config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
|
||||
|
||||
config :pinchflat,
|
||||
yt_dlp_executable: System.find_executable("yt-dlp"),
|
||||
@@ -72,7 +66,7 @@ if config_env() == :prod do
|
||||
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
database: db_path,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||
journal_mode: journal_mode
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
# A default value is used in config/dev.exs and config/test.exs but you
|
||||
|
||||
@@ -17,7 +17,6 @@ config :pinchflat, Oban, testing: :manual
|
||||
# Run `mix help test` for more information.
|
||||
config :pinchflat, Pinchflat.Repo,
|
||||
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
|
||||
pool_size: 5,
|
||||
pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
thumbnail_options(media_item_with_preloads) ++
|
||||
metadata_options(media_profile) ++
|
||||
quality_options(media_profile) ++
|
||||
sponsorblock_options(media_profile) ++
|
||||
output_options(media_item_with_preloads)
|
||||
|
||||
{:ok, built_options}
|
||||
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
defp sponsorblock_options(media_profile) do
|
||||
categories = media_profile.sponsorblock_categories
|
||||
behaviour = media_profile.sponsorblock_behaviour
|
||||
|
||||
case {behaviour, categories} do
|
||||
{_, []} -> []
|
||||
{:remove, _} -> [sponsorblock_remove: Enum.join(categories, ",")]
|
||||
{:disabled, _} -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp output_options(media_item_with_preloads) do
|
||||
[
|
||||
output: build_output_path_for(media_item_with_preloads.source)
|
||||
|
||||
@@ -5,8 +5,10 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
Many of these methods are made to be kickoff or be consumed by workers.
|
||||
"""
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.FastIndexing.YoutubeRss
|
||||
alias Pinchflat.Downloading.MediaDownloadWorker
|
||||
alias Pinchflat.FastIndexing.MediaIndexingWorker
|
||||
@@ -27,7 +29,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
"""
|
||||
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
|
||||
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source)
|
||||
existing_media_items = Media.list_media_items_by_media_id_for(source, media_ids)
|
||||
existing_media_items = list_media_items_by_media_id_for(source, media_ids)
|
||||
new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
|
||||
|
||||
Enum.each(new_media_ids, fn media_id ->
|
||||
@@ -61,6 +63,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
|
||||
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)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp create_media_item_from_url(source, url) do
|
||||
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
|
||||
|
||||
|
||||
+20
-103
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Metadata.MediaMetadata
|
||||
alias Pinchflat.Filesystem.FilesystemHelpers
|
||||
|
||||
@@ -21,33 +22,6 @@ defmodule Pinchflat.Media do
|
||||
Repo.all(MediaItem)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of media_items for a given source.
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_media_items_for(%Source{} = source) do
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetches all media items belonging to a given source that have a media_id in the given list.
|
||||
Useful for determining the what media items we DON'T already have for fast indexing.
|
||||
|
||||
NOTE: These queries are getting a little tedious. When I have the time, I should see about
|
||||
implementing a query pattern and having these compose queries from a common base. This would
|
||||
also let me compose simple queries in the module using them for one-off methods
|
||||
|
||||
Returns [%MediaItem{}, ...].
|
||||
"""
|
||||
def list_media_items_by_media_id_for(%Source{} = source, media_ids) do
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and mi.media_id in ^media_ids)
|
||||
|> 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
|
||||
@@ -60,13 +34,11 @@ defmodule Pinchflat.Media do
|
||||
"""
|
||||
def list_pending_media_items_for(%Source{} = source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
media_profile = Repo.preload(source, :media_profile).media_profile
|
||||
source = Repo.preload(source, :media_profile)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_profile))
|
||||
|> where(^maybe_apply_cutoff_date(source))
|
||||
|> where(^maybe_apply_title_regex(source))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> matching_download_criteria_for(source)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -79,8 +51,9 @@ defmodule Pinchflat.Media do
|
||||
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, nil)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -97,11 +70,9 @@ defmodule Pinchflat.Media do
|
||||
def pending_download?(%MediaItem{} = media_item) do
|
||||
media_item = Repo.preload(media_item, source: :media_profile)
|
||||
|
||||
MediaItem
|
||||
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath))
|
||||
|> where(^build_format_clauses(media_item.source.media_profile))
|
||||
|> where(^maybe_apply_cutoff_date(media_item.source))
|
||||
|> where(^maybe_apply_title_regex(media_item.source))
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.with_id(media_item.id)
|
||||
|> matching_download_criteria_for(media_item.source)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@@ -120,20 +91,9 @@ defmodule Pinchflat.Media do
|
||||
def search(search_term, opts) do
|
||||
limit = Keyword.get(opts, :limit, 50)
|
||||
|
||||
from(mi in MediaItem,
|
||||
join: mi_search_index in assoc(mi, :media_items_search_index),
|
||||
where: fragment("media_items_search_index MATCH ?", ^search_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")],
|
||||
limit: ^limit
|
||||
)
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.matching_search_term(search_term)
|
||||
|> Repo.maybe_limit(limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -241,54 +201,11 @@ defmodule Pinchflat.Media do
|
||||
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
|
||||
end
|
||||
|
||||
defp maybe_apply_cutoff_date(source) do
|
||||
if source.download_cutoff_date do
|
||||
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date)
|
||||
else
|
||||
dynamic(true)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_apply_title_regex(source) do
|
||||
if source.title_filter_regex do
|
||||
dynamic([mi], fragment("regexp_like(?, ?)", mi.title, ^source.title_filter_regex))
|
||||
else
|
||||
dynamic(true)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_format_clauses(media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||
dynamic(
|
||||
[mi],
|
||||
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
||||
)
|
||||
|
||||
# Technically redundant, but makes the other clauses easier to parse
|
||||
# (redundant because this condition is the same as the condition above, just flipped)
|
||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||
dynamic
|
||||
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||
# return records with livestream: false
|
||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||
|
||||
_ ->
|
||||
dynamic
|
||||
end
|
||||
end)
|
||||
defp matching_download_criteria_for(query, source_with_preloads) do
|
||||
query
|
||||
|> MediaQuery.with_no_media_filepath()
|
||||
|> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
|
||||
|> MediaQuery.with_format_preference(source_with_preloads.media_profile)
|
||||
|> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
:source_id,
|
||||
:short_form_content,
|
||||
:upload_date,
|
||||
:duration_seconds,
|
||||
# these fields are captured only on download
|
||||
:media_downloaded_at,
|
||||
:media_filepath,
|
||||
@@ -57,6 +58,7 @@ defmodule Pinchflat.Media.MediaItem do
|
||||
field :short_form_content, :boolean, default: false
|
||||
field :media_downloaded_at, :utc_datetime
|
||||
field :upload_date, :date
|
||||
field :duration_seconds, :integer
|
||||
|
||||
field :media_filepath, :string
|
||||
field :media_size_bytes, :integer
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
defmodule Pinchflat.Media.MediaQuery do
|
||||
@moduledoc """
|
||||
Query helpers for the Media context.
|
||||
|
||||
These methods are made to be one-ish liners used
|
||||
to compose queries for media items. 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.
|
||||
|
||||
ALSO, this is me trying something new. If I like it,
|
||||
I'll refactor other contexts to use this pattern.
|
||||
"""
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
# Prefixes:
|
||||
# - for_* - belonging to 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
|
||||
|
||||
def new do
|
||||
MediaItem
|
||||
end
|
||||
|
||||
def for_source(query, source) do
|
||||
where(query, [mi], mi.source_id == ^source.id)
|
||||
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_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(query, nil), do: query
|
||||
|
||||
def with_upload_date_after(query, date) do
|
||||
where(query, [mi], mi.upload_date >= ^date)
|
||||
end
|
||||
|
||||
def matching_title_regex(query, nil), do: query
|
||||
|
||||
def matching_title_regex(query, regex) do
|
||||
where(query, [mi], fragment("regexp_like(?, ?)", mi.title, ^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
|
||||
|
||||
# NOTE: this method breaks the contract set by other methods in that it
|
||||
# takes a media_profile struct instead of taking just the attributes it
|
||||
# cares about. Consider refactoring but low priority.
|
||||
def with_format_preference(query, media_profile) do
|
||||
mapped_struct = Map.from_struct(media_profile)
|
||||
|
||||
finders =
|
||||
Enum.reduce(mapped_struct, dynamic(true), fn attr, dynamic ->
|
||||
case {attr, media_profile} do
|
||||
{{:shorts_behaviour, :only}, %{livestream_behaviour: :only}} ->
|
||||
dynamic(
|
||||
[mi],
|
||||
^dynamic and (mi.livestream == true or mi.short_form_content == true)
|
||||
)
|
||||
|
||||
# Technically redundant, but makes the other clauses easier to parse
|
||||
# (redundant because this condition is the same as the condition above, just flipped)
|
||||
{{:livestream_behaviour, :only}, %{shorts_behaviour: :only}} ->
|
||||
dynamic
|
||||
|
||||
{{:shorts_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == true)
|
||||
|
||||
{{:livestream_behaviour, :only}, _} ->
|
||||
dynamic([mi], ^dynamic and mi.livestream == true)
|
||||
|
||||
{{:shorts_behaviour, :exclude}, %{livestream_behaviour: lb}} when lb != :only ->
|
||||
dynamic([mi], ^dynamic and mi.short_form_content == false)
|
||||
|
||||
{{:livestream_behaviour, :exclude}, %{shorts_behaviour: sb}} when sb != :only ->
|
||||
# return records with livestream: false
|
||||
dynamic([mi], ^dynamic and mi.livestream == false)
|
||||
|
||||
_ ->
|
||||
dynamic
|
||||
end
|
||||
end)
|
||||
|
||||
where(query, ^finders)
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
|
||||
original_url: metadata["original_url"],
|
||||
description: metadata["description"],
|
||||
media_filepath: metadata["filepath"],
|
||||
livestream: metadata["was_live"]
|
||||
livestream: metadata["was_live"],
|
||||
duration_seconds: metadata["duration"] && round(metadata["duration"])
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
|
||||
<link>#{media_item.original_url}</link>
|
||||
<description>#{safe(media_item.description)}</description>
|
||||
<pubDate>#{generate_upload_date(media_item)}</pubDate>
|
||||
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
|
||||
<enclosure
|
||||
url="#{media_stream_path(url_base, media_item)}"
|
||||
length="#{media_item.media_size_bytes}"
|
||||
|
||||
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
download_metadata
|
||||
embed_metadata
|
||||
download_nfo
|
||||
sponsorblock_behaviour
|
||||
sponsorblock_categories
|
||||
shorts_behaviour
|
||||
livestream_behaviour
|
||||
preferred_resolution
|
||||
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
|
||||
field :embed_metadata, :boolean, default: false
|
||||
|
||||
field :download_nfo, :boolean, default: false
|
||||
field :sponsorblock_behaviour, Ecto.Enum, values: [:disabled, :remove], default: :disabled
|
||||
field :sponsorblock_categories, {:array, :string}, default: []
|
||||
# NOTE: these do NOT speed up indexing - the indexer still has to go
|
||||
# through the entire collection to determine if a media is a short or
|
||||
# a livestream.
|
||||
|
||||
@@ -25,14 +25,15 @@ defmodule Pinchflat.Release do
|
||||
def check_file_permissions do
|
||||
load_app()
|
||||
|
||||
directories = [
|
||||
directories =
|
||||
Enum.uniq([
|
||||
"/config",
|
||||
"/downloads",
|
||||
Application.get_env(:pinchflat, :media_directory),
|
||||
Application.get_env(:pinchflat, :tmpfile_directory),
|
||||
Application.get_env(:pinchflat, :extras_directory),
|
||||
Application.get_env(:pinchflat, :metadata_directory)
|
||||
]
|
||||
])
|
||||
|
||||
Enum.each(directories, fn dir ->
|
||||
Logger.info("Checking permissions for #{dir}")
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Tasks
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
alias Pinchflat.Profiles.MediaProfile
|
||||
alias Pinchflat.YtDlp.MediaCollection
|
||||
alias Pinchflat.Metadata.SourceMetadata
|
||||
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
|
||||
delete_files = Keyword.get(opts, :delete_files, false)
|
||||
Tasks.delete_tasks_for(source)
|
||||
|
||||
source
|
||||
|> Media.list_media_items_for()
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn media_item ->
|
||||
Media.delete_media_item(media_item, delete_files: delete_files)
|
||||
end)
|
||||
|
||||
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date
|
||||
:upload_date,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
defstruct [
|
||||
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
:original_url,
|
||||
:livestream,
|
||||
:short_form_content,
|
||||
:upload_date
|
||||
:upload_date,
|
||||
:duration_seconds
|
||||
]
|
||||
|
||||
alias __MODULE__
|
||||
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
|
||||
description: response["description"],
|
||||
original_url: response["webpage_url"],
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
toggle range radio search select tel text textarea time url week)
|
||||
checkbox_group toggle range radio search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
@@ -304,6 +304,33 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox_group"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}>
|
||||
<%= @label %><span :if={@label_suffix} class="text-xs text-bodydark"><%= @label_suffix %></span>
|
||||
</.label>
|
||||
<section class="grid grid-cols-1 gap-2 md:grid-cols-2 max-w-prose mb-4 ml-1">
|
||||
<div :for={{option_name, option_value} <- @options} class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={"#{@id}-#{option_value}"}
|
||||
name={"#{@name}[]"}
|
||||
value={option_value}
|
||||
checked={option_value in @value}
|
||||
class={["rounded focus:ring-offset-0 ring-offset-0 focus:ring-0 h-5 w-5 ", @inputclass]}
|
||||
/>
|
||||
<label for={"#{@id}-#{option_value}"} class="ml-2 cursor-pointer select-none">
|
||||
<%= option_name %>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<.help :if={@help}><%= @help %></.help>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "toggle"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
@@ -601,6 +628,10 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
_ ->
|
||||
true
|
||||
end)
|
||||
|> Enum.map(fn
|
||||
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
|
||||
rest -> rest
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, iterable_attributes: attrs)
|
||||
|
||||
|
||||
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
|
||||
]
|
||||
end
|
||||
|
||||
def friendly_sponsorblock_options do
|
||||
[
|
||||
{"Disabled (default)", "disabled"},
|
||||
{"Remove Segments", "remove"}
|
||||
]
|
||||
end
|
||||
|
||||
def frieldly_sponsorblock_categories do
|
||||
[
|
||||
{"Sponsor", "sponsor"},
|
||||
{"Intro/Intermission", "intro"},
|
||||
{"Outro/Credits", "outro"},
|
||||
{"Self Promotion", "selfpromo"},
|
||||
{"Preview/Recap", "preview"},
|
||||
{"Filler Tangent", "filler"},
|
||||
{"Interaction Reminder", "interaction"},
|
||||
{"Non-music Section", "music_offtopic"}
|
||||
]
|
||||
end
|
||||
|
||||
def custom_output_template_options do
|
||||
%{
|
||||
upload_day: nil,
|
||||
|
||||
+30
@@ -238,6 +238,36 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<h3 class="mt-10 text-2xl text-black dark:text-white">
|
||||
SponsorBlock Options
|
||||
</h3>
|
||||
|
||||
<section x-data="{ sponsorblockBehaviour: null }">
|
||||
<section x-data="{ presets: { default: 'disabled', media_center: 'disabled', audio: 'disabled', archiving: 'disabled' } }">
|
||||
<.input
|
||||
field={f[:sponsorblock_behaviour]}
|
||||
options={friendly_sponsorblock_options()}
|
||||
type="select"
|
||||
label="SponsorBlock Behaviour"
|
||||
help="Action to take when SponsorBlock segments are found. 'Disabled' won't take any action"
|
||||
x-model="sponsorblockBehaviour"
|
||||
x-init="
|
||||
sponsorblockBehaviour = $el.value
|
||||
$watch('selectedPreset', p => p && ($el.value = presets[p]))
|
||||
"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section x-show="sponsorblockBehaviour !== 'disabled'" x-transition>
|
||||
<.input
|
||||
field={f[:sponsorblock_categories]}
|
||||
options={frieldly_sponsorblock_categories()}
|
||||
type="checkbox_group"
|
||||
label="SponsorBlock Categories"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
alias Pinchflat.Sources
|
||||
alias Pinchflat.Profiles
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Media.MediaItem
|
||||
alias Pinchflat.Media.MediaQuery
|
||||
|
||||
def index(conn, _params) do
|
||||
sources = Repo.preload(Sources.list_sources(), :media_profile)
|
||||
@@ -111,11 +111,9 @@ defmodule PinchflatWeb.Sources.SourceController do
|
||||
# NOTE: should move this out of the controller
|
||||
# once I finally add some query fragment layer
|
||||
defp total_downloaded_for(source) do
|
||||
from(
|
||||
m in MediaItem,
|
||||
where: m.source_id == ^source.id,
|
||||
where: not is_nil(m.media_filepath)
|
||||
)
|
||||
MediaQuery.new()
|
||||
|> MediaQuery.for_source(source)
|
||||
|> MediaQuery.with_media_filepath()
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
|
||||
def project do
|
||||
[
|
||||
app: :pinchflat,
|
||||
version: "0.1.4",
|
||||
version: "0.1.5",
|
||||
elixir: "~> 1.16",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddSponsorblockToMediaProfiles do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_profiles) do
|
||||
add :sponsorblock_behaviour, :string, default: "disabled"
|
||||
add :sponsorblock_categories, {:array, :string}, default: []
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Pinchflat.Repo.Migrations.AddDurationToMediaItems do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:media_items) do
|
||||
add :duration_seconds, :integer
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,6 +222,45 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilderTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/1 when testing sponsorblock options" do
|
||||
test "includes :sponsorblock_remove option when specified", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{
|
||||
sponsorblock_behaviour: :remove,
|
||||
sponsorblock_categories: ["sponsor", "intro"]
|
||||
})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
assert {:sponsorblock_remove, "sponsor,intro"} in res
|
||||
end
|
||||
|
||||
test "does not include :sponsorblock_remove option without categories", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{
|
||||
sponsorblock_behaviour: :remove,
|
||||
sponsorblock_categories: []
|
||||
})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
refute {:sponsorblock_remove, ""} in res
|
||||
refute {:sponsorblock_remove, []} in res
|
||||
refute :sponsorblock_remove in res
|
||||
end
|
||||
|
||||
test "does not include any sponsorblock options when disabled", %{media_item: media_item} do
|
||||
media_item =
|
||||
update_media_profile_attribute(media_item, %{sponsorblock_behaviour: :disabled})
|
||||
|
||||
assert {:ok, res} = DownloadOptionBuilder.build(media_item)
|
||||
|
||||
refute {:sponsorblock_remove, ""} in res
|
||||
refute {:sponsorblock_remove, []} in res
|
||||
refute :sponsorblock_remove in res
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_output_path_for/1" do
|
||||
test "builds an output path for a source", %{media_item: media_item} do
|
||||
path = DownloadOptionBuilder.build_output_path_for(media_item.source)
|
||||
|
||||
@@ -35,7 +35,7 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
|
||||
end
|
||||
|
||||
test "it saves the metadata filepatha to the database", %{media_item: media_item} do
|
||||
test "it saves the metadata filepath to the database", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
{:ok, render_metadata(:media_metadata)}
|
||||
end)
|
||||
@@ -93,6 +93,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
|
||||
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
|
||||
end
|
||||
|
||||
test "it extracts the duration_seconds", %{media_item: media_item} do
|
||||
assert media_item.duration_seconds == nil
|
||||
assert {:ok, updated_media_item} = MediaDownloader.download_for_media_item(media_item)
|
||||
assert is_integer(updated_media_item.duration_seconds)
|
||||
end
|
||||
|
||||
test "it extracts the thumbnail_filepath", %{media_item: media_item} do
|
||||
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
|
||||
metadata = render_parsed_metadata(:media_metadata)
|
||||
|
||||
@@ -38,32 +38,6 @@ defmodule Pinchflat.MediaTest do
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_items_for/1" do
|
||||
test "it returns media_items for a given source" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(%{source_id: source.id})
|
||||
|
||||
assert Media.list_media_items_for(source) == [media_item]
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_media_items_by_media_id_for/2" do
|
||||
test "returns media_items for a given source and media_ids" do
|
||||
source = source_fixture()
|
||||
media_item = media_item_fixture(%{source_id: source.id, media_id: "123"})
|
||||
|
||||
assert Media.list_media_items_by_media_id_for(source, ["123"]) == [media_item]
|
||||
end
|
||||
|
||||
test "does not return matching media_ids for a different source" do
|
||||
source = source_fixture()
|
||||
other_source = source_fixture()
|
||||
_media_item = media_item_fixture(%{source_id: other_source.id, media_id: "123"})
|
||||
|
||||
assert Media.list_media_items_by_media_id_for(source, ["123"]) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_pending_media_items_for/1" do
|
||||
test "it returns pending without a filepath for a given source" do
|
||||
source = source_fixture()
|
||||
|
||||
@@ -45,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
|
||||
|
||||
assert result.livestream == metadata["was_live"]
|
||||
end
|
||||
|
||||
test "it extracts the duration in seconds", %{metadata: metadata} do
|
||||
result = Parser.parse_for_media_item(metadata)
|
||||
|
||||
assert result.duration_seconds == round(metadata["duration"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_for_media_item/1 when testing subtitle metadata" do
|
||||
|
||||
@@ -104,7 +104,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
livestream: false,
|
||||
short_form_content: false,
|
||||
upload_date: Date.from_iso8601!("2021-01-01")
|
||||
upload_date: Date.from_iso8601!("2021-01-01"),
|
||||
duration_seconds: 60
|
||||
} == Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
@@ -174,5 +175,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
|
||||
|
||||
assert %Media{upload_date: nil} = Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
test "parses the duration" do
|
||||
response = %{
|
||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => 60.4,
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
assert %Media{duration_seconds: 60} = Media.response_to_struct(response)
|
||||
end
|
||||
|
||||
test "doesn't blow up if duration is missing" do
|
||||
response = %{
|
||||
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
|
||||
"aspect_ratio" => 1.0,
|
||||
"duration" => nil,
|
||||
"upload_date" => "20210101"
|
||||
}
|
||||
|
||||
assert %Media{duration_seconds: nil} = Media.response_to_struct(response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user