Skip to content

Commit

Permalink
[Bugfix] Properly escape NFO files (#168)
Browse files Browse the repository at this point in the history
* Properly escaped NFO file contents

* Added an NFO backfill worker

* Added a try-catch to the backfill since I _really_ don't want failures to halt app boot
  • Loading branch information
kieraneglin authored Apr 6, 2024
1 parent 24875ea commit 81b49f5
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 18 deletions.
70 changes: 70 additions & 0 deletions lib/pinchflat/boot/nfo_backfill_worker.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Pinchflat.Boot.NfoBackfillWorker do
@moduledoc false

use Oban.Worker,
queue: :local_metadata,
# This should have it running once _ever_ (until the job is pruned, anyway)
# NOTE: remove within the next month
unique: [period: :infinity, states: Oban.Job.states()],
tags: ["media_item", "media_metadata", "local_metadata", "data_backfill"]

import Ecto.Query, warn: false
require Logger

alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Metadata.NfoBuilder
alias Pinchflat.Metadata.MetadataFileHelpers

@doc """
Runs a one-off backfill job to regenerate NFO files for media items that have
both an NFO file and a metadata file. This is needed because NFO files weren't
escaping characters properly so we need to regenerate them.
This job will only run once as long as I remove it before the jobs are pruned in a month.
Returns :ok
"""
@impl Oban.Worker
def perform(%Oban.Job{}) do
Logger.info("Running NFO backfill worker")

media_items = get_media_items_to_backfill()

Enum.each(media_items, fn media_item ->
nfo_exists = File.exists?(media_item.nfo_filepath)
metadata_exists = File.exists?(media_item.metadata.metadata_filepath)

if nfo_exists && metadata_exists do
Logger.info("NFO and metadata exist for media item #{media_item.id} - proceeding")

regenerate_nfo_for_media_item(media_item)
end
end)

:ok
end

defp get_media_items_to_backfill do
from(m in MediaItem, where: not is_nil(m.nfo_filepath))
|> Repo.all()
|> Repo.preload([:metadata, source: :media_profile])
end

defp regenerate_nfo_for_media_item(media_item) do
try do
case MetadataFileHelpers.read_compressed_metadata(media_item.metadata.metadata_filepath) do
{:ok, metadata} ->
Media.update_media_item(media_item, %{
nfo_filepath: NfoBuilder.build_and_store_for_media_item(media_item.nfo_filepath, metadata)
})

_err ->
Logger.error("Failed to read metadata for media item #{media_item.id}")
end
rescue
e -> Logger.error("Unknown error regenerating NFO file for MI ##{media_item.id}: #{inspect(e)}")
end
end
end
6 changes: 5 additions & 1 deletion lib/pinchflat/boot/post_job_startup_tasks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
Phoenix supervision tree.
"""

alias Pinchflat.Repo
alias Pinchflat.Boot.NfoBackfillWorker

# restart: :temporary means that this process will never be restarted (ie: will run once and then die)
use GenServer, restart: :temporary
import Ecto.Query, warn: false
Expand All @@ -26,7 +29,8 @@ defmodule Pinchflat.Boot.PostJobStartupTasks do
"""
@impl true
def init(state) do
# Empty for now, keeping because tasks _will_ be added in future
Repo.insert_unique_job(NfoBackfillWorker.new(%{}))

{:ok, state}
end
end
20 changes: 11 additions & 9 deletions lib/pinchflat/metadata/nfo_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ defmodule Pinchflat.Metadata.NfoBuilder do
use by Kodi/Jellyfin and other media center software.
"""

import Pinchflat.Utils.XmlUtils, only: [safe: 1]

alias Pinchflat.Metadata.MetadataFileHelpers
alias Pinchflat.Filesystem.FilesystemHelpers

Expand Down Expand Up @@ -42,12 +44,12 @@ defmodule Pinchflat.Metadata.NfoBuilder do
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<episodedetails>
<title>#{metadata["title"]}</title>
<showtitle>#{metadata["uploader"]}</showtitle>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<plot>#{metadata["description"]}</plot>
<aired>#{upload_date}</aired>
<season>#{upload_date.year}</season>
<title>#{safe(metadata["title"])}</title>
<showtitle>#{safe(metadata["uploader"])}</showtitle>
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<plot>#{safe(metadata["description"])}</plot>
<aired>#{safe(upload_date)}</aired>
<season>#{safe(upload_date.year)}</season>
<episode>#{Calendar.strftime(upload_date, "%m%d")}</episode>
<genre>YouTube</genre>
</episodedetails>
Expand All @@ -58,9 +60,9 @@ defmodule Pinchflat.Metadata.NfoBuilder do
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<tvshow>
<title>#{metadata["title"]}</title>
<plot>#{metadata["description"]}</plot>
<uniqueid type="youtube" default="true">#{metadata["id"]}</uniqueid>
<title>#{safe(metadata["title"])}</title>
<plot>#{safe(metadata["description"])}</plot>
<uniqueid type="youtube" default="true">#{safe(metadata["id"])}</uniqueid>
<genre>YouTube</genre>
</tvshow>
"""
Expand Down
10 changes: 2 additions & 8 deletions lib/pinchflat/podcasts/rss_feed_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do

@datetime_format "%a, %d %b %Y %H:%M:%S %z"

import Pinchflat.Utils.XmlUtils, only: [safe: 1]

alias Pinchflat.Utils.DatetimeUtils
alias Pinchflat.Podcasts.PodcastHelpers
alias PinchflatWeb.Router.Helpers, as: Routes
Expand Down Expand Up @@ -94,14 +96,6 @@ defmodule Pinchflat.Podcasts.RssFeedBuilder do
"""
end

defp safe(nil), do: ""

defp safe(value) do
value
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end

defp generate_self_link(url_base, source) do
Path.join(url_base, "#{podcast_route(:rss_feed, source.uuid)}.xml")
end
Expand Down
17 changes: 17 additions & 0 deletions lib/pinchflat/utils/xml_utils.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule Pinchflat.Utils.XmlUtils do
@moduledoc """
Utility methods for working with XML documents
"""

@doc """
Escapes invalid XML characters in a string
Returns binary()
"""
def safe(value) do
value
|> to_string()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
end
end
28 changes: 28 additions & 0 deletions test/pinchflat/metadata/nfo_builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end

test "escapes invalid characters", %{filepath: filepath} do
metadata = %{
"title" => "hello' & <world>",
"uploader" => "uploader",
"id" => "id",
"description" => "description",
"upload_date" => "20210101"
}

result = NfoBuilder.build_and_store_for_media_item(filepath, metadata)
nfo = File.read!(result)

assert String.contains?(nfo, "hello&#39; &amp; &lt;world&gt;")
end
end

describe "build_and_store_for_source/2" do
Expand All @@ -46,5 +61,18 @@ defmodule Pinchflat.Metadata.NfoBuilderTest do
assert String.contains?(nfo, ~S(<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>))
assert String.contains?(nfo, "<title>#{metadata["title"]}</title>")
end

test "escapes invalid characters", %{filepath: filepath} do
metadata = %{
"title" => "hello' & <world>",
"description" => "description",
"id" => "id"
}

result = NfoBuilder.build_and_store_for_source(filepath, metadata)
nfo = File.read!(result)

assert String.contains?(nfo, "hello&#39; &amp; &lt;world&gt;")
end
end
end
16 changes: 16 additions & 0 deletions test/pinchflat/utils/xml_utils_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Pinchflat.Utils.XmlUtilsTest do
use ExUnit.Case, async: true

alias Pinchflat.Utils.XmlUtils

describe "safe/1" do
test "escapes invalid characters" do
assert XmlUtils.safe("hello' & <world>") == "hello&#39; &amp; &lt;world&gt;"
end

test "converts input to string" do
assert XmlUtils.safe(42) == "42"
assert XmlUtils.safe(nil) == ""
end
end
end

0 comments on commit 81b49f5

Please sign in to comment.