Compare commits

...

7 Commits

12 changed files with 76 additions and 36 deletions
+4 -4
View File
@@ -53,7 +53,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Self-contained - just one Docker container with no external dependencies - Self-contained - just one Docker container with no external dependencies
- Powerful naming system so content is stored where and how you want it - Powerful naming system so content is stored where and how you want it
- Easy-to-use web interface with presets to get you started right away - Easy-to-use web interface with presets to get you started right away
- First-class support for media center apps like Plex, Jellyfin, and Kodi - First-class support for media center apps like Plex, Jellyfin, and Kodi ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#how-do-i-get-media-into-plexjellyfinkodi))
- Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds)) - Supports serving RSS feeds to your favourite podcast app ([docs](https://github.com/kieraneglin/pinchflat/wiki/Podcast-RSS-Feeds))
- Automatically downloads new content from channels and playlists - Automatically downloads new content from channels and playlists
- Uses a novel approach to download new content more quickly than other apps - Uses a novel approach to download new content more quickly than other apps
@@ -63,7 +63,7 @@ If it doesn't work for your use case, please make a feature request! You can als
- Allows automatically redownloading new media after a set period - Allows automatically redownloading new media after a set period
- This can help improve the download quality of new content or improve SponsorBlock tags - This can help improve the download quality of new content or improve SponsorBlock tags
- Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media)) - Optionally automatically delete old content ([docs](https://github.com/kieraneglin/pinchflat/wiki/Automatically-Delete-Media))
- Advanced options like setting cutoff dates and filtering by title - Advanced options like setting cutoff dates and filtering by title ([docs](https://github.com/kieraneglin/pinchflat/wiki/Frequently-Asked-Questions#i-only-want-certain-videos-from-a-source---how-can-i-only-download-those))
- Reliable hands-off operation - Reliable hands-off operation
- Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies)) - Can pass cookies to YouTube to download your private playlists ([docs](https://github.com/kieraneglin/pinchflat/wiki/YouTube-Cookies))
- Sponsorblock integration - Sponsorblock integration
@@ -161,9 +161,9 @@ Prior to 2024-05-10, a portion of all donations were given to the [Electronic Fr
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. 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.
## Pre-release disclaimer ## Stability disclaimer
This is pre-release software and anything can break at any time. I make not guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat). Essentially, use at your own risk and expect there will be rough edges for now. This software is in active development and anything can break at any time. I make no guarantees about the stability of this software, forward-compatibility of updates, or integrity (both related to and independent of Pinchflat).
## License ## License
+3 -2
View File
@@ -79,11 +79,12 @@ if config_env() == :prod do
tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]), tmpfile_directory: Path.join([System.tmp_dir!(), "pinchflat", "data"]),
dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"), dns_cluster_query: System.get_env("DNS_CLUSTER_QUERY"),
expose_feed_endpoints: expose_feed_endpoints, expose_feed_endpoints: expose_feed_endpoints,
timezone: System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC", # This is configured in application.ex
timezone: "UTC",
log_path: log_path, log_path: log_path,
base_route_path: base_route_path base_route_path: base_route_path
config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/etc/elixir_tzdata_data") config :tzdata, :data_dir, System.get_env("TZ_DATA_DIR", "/config/extras/elixir_tz_data")
config :pinchflat, Pinchflat.Repo, config :pinchflat, Pinchflat.Repo,
database: db_path, database: db_path,
+19
View File
@@ -4,9 +4,12 @@ defmodule Pinchflat.Application do
@moduledoc false @moduledoc false
use Application use Application
require Logger
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
check_and_update_timezone()
children = [ children = [
PinchflatWeb.Telemetry, PinchflatWeb.Telemetry,
Pinchflat.Repo, Pinchflat.Repo,
@@ -47,4 +50,20 @@ defmodule Pinchflat.Application do
:ok = Oban.Telemetry.attach_default_logger() :ok = Oban.Telemetry.attach_default_logger()
:telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, []) :telemetry.attach_many("job-telemetry-broadcast", events, &PinchflatWeb.Telemetry.job_state_change_broadcast/4, [])
end end
# This has to be here (rather than runtime.exs) since the `tzdata` application
# has to be started before we can check the timezone
defp check_and_update_timezone do
attempted_timezone = System.get_env("TIMEZONE") || System.get_env("TZ") || "UTC"
valid_timezone =
if Tzdata.zone_exists?(attempted_timezone) do
attempted_timezone
else
Logger.warning("Invalid timezone #{attempted_timezone}, defaulting to UTC")
"UTC"
end
Application.put_env(:pinchflat, :timezone, valid_timezone)
end
end end
+7 -4
View File
@@ -15,6 +15,9 @@ defmodule Pinchflat.Media do
alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner alias Pinchflat.Lifecycle.UserScripts.CommandRunner, as: UserScriptRunner
# Some fields should only be set on insert and not on update.
@fields_to_drop_on_update [:playlist_index]
@doc """ @doc """
Returns the list of media_items. Returns the list of media_items.
@@ -131,8 +134,6 @@ defmodule Pinchflat.Media do
""" """
def create_media_item_from_backend_attrs(source, media_attrs_struct) do def create_media_item_from_backend_attrs(source, media_attrs_struct) do
attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct)) attrs = Map.merge(%{source_id: source.id}, Map.from_struct(media_attrs_struct))
# Some fields should only be set on insert and not on update.
fields_to_drop_on_update = [:playlist_index]
%MediaItem{} %MediaItem{}
|> MediaItem.changeset(attrs) |> MediaItem.changeset(attrs)
@@ -140,7 +141,7 @@ defmodule Pinchflat.Media do
on_conflict: [ on_conflict: [
set: set:
attrs attrs
|> Map.drop(fields_to_drop_on_update) |> Map.drop(@fields_to_drop_on_update)
|> Map.to_list() |> Map.to_list()
], ],
conflict_target: [:source_id, :media_id] conflict_target: [:source_id, :media_id]
@@ -153,8 +154,10 @@ defmodule Pinchflat.Media do
Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}} Returns {:ok, %MediaItem{}} | {:error, %Ecto.Changeset{}}
""" """
def update_media_item(%MediaItem{} = media_item, attrs) do def update_media_item(%MediaItem{} = media_item, attrs) do
update_attrs = Map.drop(attrs, @fields_to_drop_on_update)
media_item media_item
|> MediaItem.changeset(attrs) |> MediaItem.changeset(update_attrs)
|> Repo.update() |> Repo.update()
end end
+6 -4
View File
@@ -87,9 +87,11 @@ defmodule Pinchflat.YtDlp.Media do
Returns the output template for yt-dlp's indexing command. Returns the output template for yt-dlp's indexing command.
NOTE: playlist_index is really only useful for playlists that will never change their order. NOTE: playlist_index is really only useful for playlists that will never change their order.
NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
if something is a short via the URL again
""" """
def indexing_output_template do def indexing_output_template do
"%(.{id,title,live_status,webpage_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j" "%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index})j"
end end
@doc """ @doc """
@@ -103,17 +105,17 @@ defmodule Pinchflat.YtDlp.Media do
media_id: response["id"], media_id: response["id"],
title: response["title"], title: response["title"],
description: response["description"], description: response["description"],
original_url: response["webpage_url"], original_url: response["original_url"],
livestream: !!response["live_status"] && response["live_status"] != "not_live", livestream: !!response["live_status"] && response["live_status"] != "not_live",
duration_seconds: response["duration"] && round(response["duration"]), duration_seconds: response["duration"] && round(response["duration"]),
short_form_content: response["webpage_url"] && short_form_content?(response), short_form_content: response["original_url"] && short_form_content?(response),
uploaded_at: response["upload_date"] && parse_uploaded_at(response), uploaded_at: response["upload_date"] && parse_uploaded_at(response),
playlist_index: response["playlist_index"] || 0 playlist_index: response["playlist_index"] || 0
} }
end end
defp short_form_content?(response) do defp short_form_content?(response) do
if String.contains?(response["webpage_url"], "/shorts/") do if String.contains?(response["original_url"], "/shorts/") do
true true
else else
# Sometimes shorts are returned without /shorts/ in the URL, # Sometimes shorts are returned without /shorts/ in the URL,
+1 -1
View File
@@ -4,7 +4,7 @@ defmodule Pinchflat.MixProject do
def project do def project do
[ [
app: :pinchflat, app: :pinchflat,
version: "2024.10.25", version: "2024.10.30",
elixir: "~> 1.17", elixir: "~> 1.17",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@@ -100,7 +100,7 @@ defmodule Pinchflat.FastIndexing.FastIndexingHelpersTest do
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/shorts/video2", original_url: "https://example.com/shorts/video2",
live_status: "is_live", live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
+15
View File
@@ -712,6 +712,21 @@ defmodule Pinchflat.MediaTest do
assert media_item.media_filepath == update_attrs.media_filepath assert media_item.media_filepath == update_attrs.media_filepath
end end
test "updating strips playlist_index from the provided attrs" do
media_item = media_item_fixture(playlist_index: 5)
update_attrs = %{
media_id: Faker.String.base64(12),
title: Faker.Commerce.product_name(),
media_filepath: "/video/#{Faker.File.file_name(:video)}",
source_id: source_fixture().id,
playlist_index: 1
}
assert {:ok, %MediaItem{} = media_item} = Media.update_media_item(media_item, update_attrs)
assert media_item.playlist_index == 5
end
test "updating with invalid data returns error changeset" do test "updating with invalid data returns error changeset" do
media_item = media_item_fixture() media_item = media_item_fixture()
assert {:error, %Ecto.Changeset{}} = Media.update_media_item(media_item, @invalid_attrs) assert {:error, %Ecto.Changeset{}} = Media.update_media_item(media_item, @invalid_attrs)
@@ -188,7 +188,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
description: "desc3", description: "desc3",
# Only focusing on these because these are passed to functions that # Only focusing on these because these are passed to functions that
# could fail if they're not present # could fail if they're not present
webpage_url: nil, original_url: nil,
aspect_ratio: nil, aspect_ratio: nil,
duration: nil, duration: nil,
upload_date: nil upload_date: nil
@@ -299,7 +299,7 @@ defmodule Pinchflat.SlowIndexing.SlowIndexingHelpersTest do
Phoenix.json_library().encode!(%{ Phoenix.json_library().encode!(%{
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/shorts/video2", original_url: "https://example.com/shorts/video2",
live_status: "is_live", live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
+14 -14
View File
@@ -139,7 +139,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "indexing_output_template/0" do describe "indexing_output_template/0" do
test "contains all the greatest hits" do test "contains all the greatest hits" do
attrs = attrs =
~w(id title live_status webpage_url description aspect_ratio duration upload_date timestamp playlist_index)a ~w(id title live_status original_url description aspect_ratio duration upload_date timestamp playlist_index)a
formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j" formatted_attrs = "%(.{#{Enum.join(attrs, ",")}})j"
@@ -153,7 +153,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
"id" => "TiZPUDkDYbk", "id" => "TiZPUDkDYbk",
"title" => "Trying to Wheelie Without the Rear Brake", "title" => "Trying to Wheelie Without the Rear Brake",
"description" => "I'm not sure what I expected.", "description" => "I'm not sure what I expected.",
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"live_status" => "not_live", "live_status" => "not_live",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 60, "duration" => 60,
@@ -177,7 +177,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets short_form_content to true if the URL contains /shorts/" do test "sets short_form_content to true if the URL contains /shorts/" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk", "original_url" => "https://www.youtube.com/shorts/TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -188,7 +188,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets short_form_content to true if the aspect ratio are duration are right" do test "sets short_form_content to true if the aspect ratio are duration are right" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 0.5, "aspect_ratio" => 0.5,
"duration" => 59, "duration" => 59,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -199,7 +199,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets short_form_content to false otherwise" do test "sets short_form_content to false otherwise" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -210,7 +210,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if short form content-related fields are missing" do test "doesn't blow up if short form content-related fields are missing" do
response = %{ response = %{
"webpage_url" => nil, "original_url" => nil,
"aspect_ratio" => nil, "aspect_ratio" => nil,
"duration" => nil, "duration" => nil,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -221,7 +221,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the duration" do test "parses the duration" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 60.4, "duration" => 60.4,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -232,7 +232,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if duration is missing" do test "doesn't blow up if duration is missing" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => nil, "duration" => nil,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -243,7 +243,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "sets livestream to false if the live_status field isn't present" do test "sets livestream to false if the live_status field isn't present" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 60, "duration" => 60,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -254,7 +254,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if playlist_index is missing" do test "doesn't blow up if playlist_index is missing" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => nil, "duration" => nil,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -267,7 +267,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
describe "response_to_struct/1 when testing uploaded_at" do describe "response_to_struct/1 when testing uploaded_at" do
test "parses the upload date from the timestamp if present" do test "parses the upload date from the timestamp if present" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => "20210101", "upload_date" => "20210101",
@@ -281,7 +281,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the upload date from the uploaded_at if timestamp is present but nil" do test "parses the upload date from the uploaded_at if timestamp is present but nil" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => "20210101", "upload_date" => "20210101",
@@ -295,7 +295,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "parses the upload date from the uploaded_at if timestamp absent" do test "parses the upload date from the uploaded_at if timestamp absent" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => "20210101" "upload_date" => "20210101"
@@ -308,7 +308,7 @@ defmodule Pinchflat.YtDlp.MediaTest do
test "doesn't blow up if upload date is missing" do test "doesn't blow up if upload date is missing" do
response = %{ response = %{
"webpage_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk", "original_url" => "https://www.youtube.com/watch?v=TiZPUDkDYbk",
"aspect_ratio" => 1.0, "aspect_ratio" => 1.0,
"duration" => 61, "duration" => 61,
"upload_date" => nil "upload_date" => nil
+1 -1
View File
@@ -95,7 +95,7 @@ defmodule Pinchflat.MediaFixtures do
media_attributes = %{ media_attributes = %{
id: "video1", id: "video1",
title: "Video 1", title: "Video 1",
webpage_url: "https://example.com/video1", original_url: "https://example.com/video1",
live_status: "not_live", live_status: "not_live",
description: "desc1", description: "desc1",
aspect_ratio: 1.67, aspect_ratio: 1.67,
+3 -3
View File
@@ -80,7 +80,7 @@ defmodule Pinchflat.SourcesFixtures do
%{ %{
id: "video1", id: "video1",
title: "Video 1", title: "Video 1",
webpage_url: "https://example.com/video1", original_url: "https://example.com/video1",
live_status: "not_live", live_status: "not_live",
description: "desc1", description: "desc1",
aspect_ratio: 1.67, aspect_ratio: 1.67,
@@ -90,7 +90,7 @@ defmodule Pinchflat.SourcesFixtures do
%{ %{
id: "video2", id: "video2",
title: "Video 2", title: "Video 2",
webpage_url: "https://example.com/video2", original_url: "https://example.com/video2",
live_status: "is_live", live_status: "is_live",
description: "desc2", description: "desc2",
aspect_ratio: 1.67, aspect_ratio: 1.67,
@@ -100,7 +100,7 @@ defmodule Pinchflat.SourcesFixtures do
%{ %{
id: "video3", id: "video3",
title: "Video 3", title: "Video 3",
webpage_url: "https://example.com/video3", original_url: "https://example.com/video3",
live_status: "not_live", live_status: "not_live",
description: "desc3", description: "desc3",
aspect_ratio: 1.0, aspect_ratio: 1.0,