Compare commits

...

9 Commits

Author SHA1 Message Date
Kieran Eglin 21eaeb8b60 Bumped version 2024-03-28 17:02:38 -07:00
Kieran Eglin 2b348607bf Combined old and new GH actions for docker 2024-03-28 16:40:50 -07:00
Kieran Eglin 2affcd8616 Testing conditionals 2024-03-28 16:32:54 -07:00
Kieran cab4c339e5 [Housekeeping] Add release workflow (takeover) (#147)
* [Housekeeping] Add release workflow (#141)

* add release workflow

This will create tags when a new release is published

* fix(workflows): refactor release workflow

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Update release.yml

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Linting

* Testing changes

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
Co-authored-by: Devin Buhl <onedr0p@users.noreply.github.com>
2024-03-28 16:14:54 -07:00
Kieran 103541cd20 [Enhancement] Start tracking a media item's duration (#146)
* Added duration field, started importing it during indexing and download

* Added duration to RSS feed
2024-03-28 15:51:04 -07:00
Kieran Eglin 8cba501bff fixed typo 2024-03-28 15:11:12 -07:00
Kieran 6a02f217e3 [Triage] Allow changing SQLite journal mode (#145)
* Adds ability to configure journal mode for SQLite

* docs

* docs
2024-03-28 15:10:27 -07:00
Kieran c3f49184b5 [Feature] Add SponsorBlock-based section removal (#144)
* Added sponsorblock columns to media profile

* Added sponsorblock options to profile form

* Added SB to download options builder
2024-03-28 12:02:13 -07:00
Kieran 3837a0dedd trying something new with queries (#142) 2024-03-28 08:59:53 -07:00
29 changed files with 468 additions and 218 deletions
@@ -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 }}
+79
View File
@@ -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 }}
+10
View File
@@ -65,6 +65,8 @@ Simply search for Pinchflat in the Community Apps store!
### Portainer ### 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: Docker Compose file:
```yaml ```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. 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 ## 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. 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.
+4
View File
@@ -25,6 +25,10 @@ config :pinchflat,
expose_feed_endpoints: false, expose_feed_endpoints: false,
file_watcher_poll_interval: 1000 file_watcher_poll_interval: 1000
config :pinchflat, Pinchflat.Repo,
journal_mode: :wal,
pool_size: 5
# Configures the endpoint # Configures the endpoint
config :pinchflat, PinchflatWeb.Endpoint, config :pinchflat, PinchflatWeb.Endpoint,
url: [host: "localhost", port: 8945], url: [host: "localhost", port: 8945],
+4 -10
View File
@@ -50,16 +50,10 @@ if config_env() == :prod do
# For running PF as a podcast host on self-hosted environments # For running PF as a podcast host on self-hosted environments
expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0 expose_feed_endpoints = String.length(System.get_env("EXPOSE_FEED_ENDPOINTS", "")) > 0
# We want to force _some_ level of useful logging in production # For testing alternate journal modes (see issue #137)
acceptable_log_levels = ~w(debug info)a journal_mode = String.to_existing_atom(System.get_env("JOURNAL_MODE", "wal"))
log_level = String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
if log_level in acceptable_log_levels do config :logger, level: String.to_existing_atom(System.get_env("LOG_LEVEL", "info"))
config :logger, level: log_level
else
Logger.error("Invalid log level: #{log_level}. Defaulting to info.")
config :logger, level: :info
end
config :pinchflat, config :pinchflat,
yt_dlp_executable: System.find_executable("yt-dlp"), yt_dlp_executable: System.find_executable("yt-dlp"),
@@ -72,7 +66,7 @@ if config_env() == :prod do
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: db_path, 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. # 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 # A default value is used in config/dev.exs and config/test.exs but you
-1
View File
@@ -17,7 +17,6 @@ config :pinchflat, Oban, testing: :manual
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)), database: Path.expand("../priv/repo/pinchflat_test.db", Path.dirname(__ENV__.file)),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required, # 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) ++ thumbnail_options(media_item_with_preloads) ++
metadata_options(media_profile) ++ metadata_options(media_profile) ++
quality_options(media_profile) ++ quality_options(media_profile) ++
sponsorblock_options(media_profile) ++
output_options(media_item_with_preloads) output_options(media_item_with_preloads)
{:ok, built_options} {:ok, built_options}
@@ -116,6 +117,17 @@ defmodule Pinchflat.Downloading.DownloadOptionBuilder do
end end
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 defp output_options(media_item_with_preloads) do
[ [
output: build_output_path_for(media_item_with_preloads.source) 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. Many of these methods are made to be kickoff or be consumed by workers.
""" """
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.FastIndexing.YoutubeRss alias Pinchflat.FastIndexing.YoutubeRss
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
alias Pinchflat.FastIndexing.MediaIndexingWorker alias Pinchflat.FastIndexing.MediaIndexingWorker
@@ -27,7 +29,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
""" """
def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do def kickoff_indexing_tasks_from_youtube_rss_feed(%Source{} = source) do
{:ok, media_ids} = YoutubeRss.get_recent_media_ids_from_rss(source) {: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) new_media_ids = media_ids -- Enum.map(existing_media_items, & &1.media_id)
Enum.each(new_media_ids, fn media_id -> Enum.each(new_media_ids, fn media_id ->
@@ -61,6 +63,13 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpers do
end end
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 defp create_media_item_from_url(source, url) do
{:ok, media_attrs} = YtDlpMedia.get_media_attributes(url) {:ok, media_attrs} = YtDlpMedia.get_media_attributes(url)
+20 -103
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Media do
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Metadata.MediaMetadata alias Pinchflat.Metadata.MediaMetadata
alias Pinchflat.Filesystem.FilesystemHelpers alias Pinchflat.Filesystem.FilesystemHelpers
@@ -21,33 +22,6 @@ defmodule Pinchflat.Media do
Repo.all(MediaItem) Repo.all(MediaItem)
end 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 """ @doc """
Returns a list of pending media_items for a given source, where Returns a list of pending media_items for a given source, where
pending means the `media_filepath` is `nil` AND the media_item 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 def list_pending_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil) limit = Keyword.get(opts, :limit, nil)
media_profile = Repo.preload(source, :media_profile).media_profile source = Repo.preload(source, :media_profile)
MediaItem MediaQuery.new()
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath)) |> MediaQuery.for_source(source)
|> where(^build_format_clauses(media_profile)) |> matching_download_criteria_for(source)
|> where(^maybe_apply_cutoff_date(source))
|> where(^maybe_apply_title_regex(source))
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -79,8 +51,9 @@ defmodule Pinchflat.Media do
def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil) limit = Keyword.get(opts, :limit, nil)
MediaItem MediaQuery.new()
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath)) |> MediaQuery.for_source(source)
|> MediaQuery.with_media_filepath()
|> Repo.maybe_limit(limit) |> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@@ -97,11 +70,9 @@ defmodule Pinchflat.Media do
def pending_download?(%MediaItem{} = media_item) do def pending_download?(%MediaItem{} = media_item) do
media_item = Repo.preload(media_item, source: :media_profile) media_item = Repo.preload(media_item, source: :media_profile)
MediaItem MediaQuery.new()
|> where([mi], mi.id == ^media_item.id and is_nil(mi.media_filepath)) |> MediaQuery.with_id(media_item.id)
|> where(^build_format_clauses(media_item.source.media_profile)) |> matching_download_criteria_for(media_item.source)
|> where(^maybe_apply_cutoff_date(media_item.source))
|> where(^maybe_apply_title_regex(media_item.source))
|> Repo.exists?() |> Repo.exists?()
end end
@@ -120,20 +91,9 @@ defmodule Pinchflat.Media do
def search(search_term, opts) do def search(search_term, opts) do
limit = Keyword.get(opts, :limit, 50) limit = Keyword.get(opts, :limit, 50)
from(mi in MediaItem, MediaQuery.new()
join: mi_search_index in assoc(mi, :media_items_search_index), |> MediaQuery.matching_search_term(search_term)
where: fragment("media_items_search_index MATCH ?", ^search_term), |> Repo.maybe_limit(limit)
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
)
|> Repo.all() |> Repo.all()
end end
@@ -241,54 +201,11 @@ defmodule Pinchflat.Media do
|> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1) |> Enum.each(&FilesystemHelpers.delete_file_and_remove_empty_directories/1)
end end
defp maybe_apply_cutoff_date(source) do defp matching_download_criteria_for(query, source_with_preloads) do
if source.download_cutoff_date do query
dynamic([mi], mi.upload_date >= ^source.download_cutoff_date) |> MediaQuery.with_no_media_filepath()
else |> MediaQuery.with_upload_date_after(source_with_preloads.download_cutoff_date)
dynamic(true) |> MediaQuery.with_format_preference(source_with_preloads.media_profile)
end |> MediaQuery.matching_title_regex(source_with_preloads.title_filter_regex)
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)
end end
end end
+2
View File
@@ -22,6 +22,7 @@ defmodule Pinchflat.Media.MediaItem do
:source_id, :source_id,
:short_form_content, :short_form_content,
:upload_date, :upload_date,
:duration_seconds,
# these fields are captured only on download # these fields are captured only on download
:media_downloaded_at, :media_downloaded_at,
:media_filepath, :media_filepath,
@@ -57,6 +58,7 @@ defmodule Pinchflat.Media.MediaItem do
field :short_form_content, :boolean, default: false field :short_form_content, :boolean, default: false
field :media_downloaded_at, :utc_datetime field :media_downloaded_at, :utc_datetime
field :upload_date, :date field :upload_date, :date
field :duration_seconds, :integer
field :media_filepath, :string field :media_filepath, :string
field :media_size_bytes, :integer field :media_size_bytes, :integer
+120
View File
@@ -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
+2 -1
View File
@@ -30,7 +30,8 @@ defmodule Pinchflat.Metadata.MetadataParser do
original_url: metadata["original_url"], original_url: metadata["original_url"],
description: metadata["description"], description: metadata["description"],
media_filepath: metadata["filepath"], media_filepath: metadata["filepath"],
livestream: metadata["was_live"] livestream: metadata["was_live"],
duration_seconds: metadata["duration"] && round(metadata["duration"])
} }
end end
@@ -80,6 +80,7 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
<link>#{media_item.original_url}</link> <link>#{media_item.original_url}</link>
<description>#{safe(media_item.description)}</description> <description>#{safe(media_item.description)}</description>
<pubDate>#{generate_upload_date(media_item)}</pubDate> <pubDate>#{generate_upload_date(media_item)}</pubDate>
<itunes:duration>#{media_item.duration_seconds}</itunes:duration>
<enclosure <enclosure
url="#{media_stream_path(url_base, media_item)}" url="#{media_stream_path(url_base, media_item)}"
length="#{media_item.media_size_bytes}" length="#{media_item.media_size_bytes}"
+4
View File
@@ -21,6 +21,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
download_metadata download_metadata
embed_metadata embed_metadata
download_nfo download_nfo
sponsorblock_behaviour
sponsorblock_categories
shorts_behaviour shorts_behaviour
livestream_behaviour livestream_behaviour
preferred_resolution preferred_resolution
@@ -47,6 +49,8 @@ defmodule Pinchflat.Profiles.MediaProfile do
field :embed_metadata, :boolean, default: false field :embed_metadata, :boolean, default: false
field :download_nfo, :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 # 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 # through the entire collection to determine if a media is a short or
# a livestream. # a livestream.
+9 -8
View File
@@ -25,14 +25,15 @@ defmodule Pinchflat.Release do
def check_file_permissions do def check_file_permissions do
load_app() load_app()
directories = [ directories =
"/config", Enum.uniq([
"/downloads", "/config",
Application.get_env(:pinchflat, :media_directory), "/downloads",
Application.get_env(:pinchflat, :tmpfile_directory), Application.get_env(:pinchflat, :media_directory),
Application.get_env(:pinchflat, :extras_directory), Application.get_env(:pinchflat, :tmpfile_directory),
Application.get_env(:pinchflat, :metadata_directory) Application.get_env(:pinchflat, :extras_directory),
] Application.get_env(:pinchflat, :metadata_directory)
])
Enum.each(directories, fn dir -> Enum.each(directories, fn dir ->
Logger.info("Checking permissions for #{dir}") Logger.info("Checking permissions for #{dir}")
+4 -2
View File
@@ -9,6 +9,7 @@ defmodule Pinchflat.Sources do
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaQuery
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.YtDlp.MediaCollection alias Pinchflat.YtDlp.MediaCollection
alias Pinchflat.Metadata.SourceMetadata alias Pinchflat.Metadata.SourceMetadata
@@ -112,8 +113,9 @@ defmodule Pinchflat.Sources do
delete_files = Keyword.get(opts, :delete_files, false) delete_files = Keyword.get(opts, :delete_files, false)
Tasks.delete_tasks_for(source) Tasks.delete_tasks_for(source)
source MediaQuery.new()
|> Media.list_media_items_for() |> MediaQuery.for_source(source)
|> Repo.all()
|> Enum.each(fn media_item -> |> Enum.each(fn media_item ->
Media.delete_media_item(media_item, delete_files: delete_files) Media.delete_media_item(media_item, delete_files: delete_files)
end) end)
+5 -2
View File
@@ -10,7 +10,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date :upload_date,
:duration_seconds
] ]
defstruct [ defstruct [
@@ -20,7 +21,8 @@ defmodule Pinchflat.YtDlp.Media do
:original_url, :original_url,
:livestream, :livestream,
:short_form_content, :short_form_content,
:upload_date :upload_date,
:duration_seconds
] ]
alias __MODULE__ alias __MODULE__
@@ -86,6 +88,7 @@ defmodule Pinchflat.YtDlp.Media do
description: response["description"], description: response["description"],
original_url: response["webpage_url"], original_url: response["webpage_url"],
livestream: response["was_live"], livestream: response["was_live"],
duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response), short_form_content: response["webpage_url"] && short_form_content?(response),
upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"]) upload_date: response["upload_date"] && MetadataFileHelpers.parse_upload_date(response["upload_date"])
} }
@@ -251,7 +251,7 @@ defmodule PinchflatWeb.CoreComponents do
attr :type, :string, attr :type, :string,
default: "text", default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password 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]" 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 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 def input(%{type: "toggle"} = assigns) do
assigns = assigns =
assign_new(assigns, :checked, fn -> assign_new(assigns, :checked, fn ->
@@ -601,6 +628,10 @@ defmodule PinchflatWeb.CoreComponents do
_ -> _ ->
true true
end) end)
|> Enum.map(fn
{k, v} when is_list(v) -> {k, Enum.join(v, ", ")}
rest -> rest
end)
assigns = assign(assigns, iterable_attributes: attrs) assigns = assign(assigns, iterable_attributes: attrs)
@@ -32,6 +32,26 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileHTML do
] ]
end 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 def custom_output_template_options do
%{ %{
upload_day: nil, upload_day: nil,
@@ -238,6 +238,36 @@
/> />
</section> </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> <.button class="my-10 sm:mb-7.5 w-full sm:w-auto" rounding="rounded-lg">Save Media profile</.button>
</section> </section>
@@ -9,7 +9,7 @@ defmodule PinchflatWeb.Sources.SourceController do
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Sources.Source alias Pinchflat.Sources.Source
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaQuery
def index(conn, _params) do def index(conn, _params) do
sources = Repo.preload(Sources.list_sources(), :media_profile) 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 # NOTE: should move this out of the controller
# once I finally add some query fragment layer # once I finally add some query fragment layer
defp total_downloaded_for(source) do defp total_downloaded_for(source) do
from( MediaQuery.new()
m in MediaItem, |> MediaQuery.for_source(source)
where: m.source_id == ^source.id, |> MediaQuery.with_media_filepath()
where: not is_nil(m.media_filepath)
)
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end end
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "0.1.4", version: "0.1.5",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -0,0 +1,10 @@
defmodule Pinchflat.Repo.Migrations.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
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 describe "build_output_path_for/1" do
test "builds an output path for a source", %{media_item: media_item} do test "builds an output path for a source", %{media_item: media_item} do
path = DownloadOptionBuilder.build_output_path_for(media_item.source) 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) assert {:ok, _} = MediaDownloader.download_for_media_item(media_item)
end 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 -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
{:ok, render_metadata(:media_metadata)} {:ok, render_metadata(:media_metadata)}
end) end)
@@ -93,6 +93,12 @@ defmodule Pinchflat.Downloading.MediaDownloaderTest do
assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths assert [["de", _], ["en", _] | _rest] = updated_media_item.subtitle_filepaths
end 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 test "it extracts the thumbnail_filepath", %{media_item: media_item} do
expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot -> expect(YtDlpRunnerMock, :run, fn _url, _opts, _ot ->
metadata = render_parsed_metadata(:media_metadata) metadata = render_parsed_metadata(:media_metadata)
-26
View File
@@ -38,32 +38,6 @@ defmodule Pinchflat.MediaTest do
end end
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 describe "list_pending_media_items_for/1" do
test "it returns pending without a filepath for a given source" do test "it returns pending without a filepath for a given source" do
source = source_fixture() source = source_fixture()
@@ -45,6 +45,12 @@ defmodule Pinchflat.Metadata.MetadataParserTest do
assert result.livestream == metadata["was_live"] assert result.livestream == metadata["was_live"]
end 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 end
describe "parse_for_media_item/1 when testing subtitle metadata" do describe "parse_for_media_item/1 when testing subtitle metadata" do
+24 -1
View File
@@ -104,7 +104,8 @@ defmodule Pinchflat.YtDlp.MediaTest do
original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk", original_url: "https://www.youtube.com/watch?v=TiZPUDkDYbk",
livestream: false, livestream: false,
short_form_content: false, short_form_content: false,
upload_date: Date.from_iso8601!("2021-01-01") upload_date: Date.from_iso8601!("2021-01-01"),
duration_seconds: 60
} == Media.response_to_struct(response) } == Media.response_to_struct(response)
end end
@@ -174,5 +175,27 @@ defmodule Pinchflat.YtDlp.MediaTest do
assert %Media{upload_date: nil} = Media.response_to_struct(response) assert %Media{upload_date: nil} = Media.response_to_struct(response)
end 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
end end