defmodule BDS.Media do @moduledoc false import BDS.Media.FileOps, only: [ attr: 2, delete_file_if_present: 2, detect_mime: 1, image_dimensions: 2, maybe_put: 3, media_file_path: 2 ] import BDS.Media.Sidecars, only: [ translation_sidecar_path: 2, write_sidecar: 2, write_translation_sidecar: 3 ] import BDS.Media.Thumbnails, only: [ delete_thumbnail_files: 2, ensure_thumbnails: 2 ] import Ecto.Query alias BDS.Media.Media alias BDS.Media.Translation alias BDS.Persistence alias BDS.Projects alias BDS.Repo alias BDS.Search @typedoc "An attribute map that may use atom or string keys." @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @typedoc "Options accepted by long-running rebuild operations." @type rebuild_opts :: keyword() # Public API delegations to submodules defdelegate thumbnail_paths(media), to: BDS.Media.Thumbnails defdelegate regenerate_thumbnails(media_id), to: BDS.Media.Thumbnails defdelegate regenerate_missing_thumbnails(project_id), to: BDS.Media.Thumbnails defdelegate regenerate_missing_thumbnails(project_id, opts), to: BDS.Media.Thumbnails defdelegate sync_media_sidecar(media_id), to: BDS.Media.Sidecars defdelegate sync_media_from_sidecar(media_id), to: BDS.Media.Sidecars defdelegate sync_media_translation_sidecar(translation_id), to: BDS.Media.Sidecars defdelegate sync_media_translation_from_sidecar(translation_id), to: BDS.Media.Sidecars defdelegate import_orphan_media_sidecar(project_id, relative_path), to: BDS.Media.Sidecars defdelegate import_orphan_media_translation_sidecar(project_id, relative_path), to: BDS.Media.Sidecars defdelegate list_linked_posts(media_id), to: BDS.Media.Linking defdelegate link_media_to_post(media_id, post_id), to: BDS.Media.Linking defdelegate unlink_media_from_post(media_id, post_id), to: BDS.Media.Linking defdelegate rebuild_media_from_files(project_id), to: BDS.Media.Rebuilder defdelegate rebuild_media_from_files(project_id, opts), to: BDS.Media.Rebuilder @spec import_media(attrs()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t() | term()} def import_media(attrs) do project = Projects.get_project!(attr(attrs, :project_id)) source_path = attr(attrs, :source_path) original_name = Path.basename(source_path) mime_type = detect_mime(original_name) {width, height} = image_dimensions(source_path, mime_type) now = Persistence.now_ms() file_name = Ecto.UUID.generate() <> Path.extname(original_name) file_path = media_file_path(file_name, now) sidecar_path = file_path <> ".meta" destination = Path.join(Projects.project_data_dir(project), file_path) stat = File.stat!(source_path) :ok = File.mkdir_p(Path.dirname(destination)) :ok = File.cp(source_path, destination) case Repo.transaction(fn -> %Media{} |> Media.changeset(%{ id: Ecto.UUID.generate(), project_id: project.id, filename: file_name, original_name: original_name, mime_type: mime_type, size: stat.size, width: attr(attrs, :width) || width, height: attr(attrs, :height) || height, title: attr(attrs, :title), alt: attr(attrs, :alt), caption: attr(attrs, :caption), author: attr(attrs, :author), language: attr(attrs, :language), file_path: file_path, sidecar_path: sidecar_path, checksum: attr(attrs, :checksum), tags: attr(attrs, :tags) || [], created_at: now, updated_at: now }) |> Repo.insert!() end) do {:ok, media} -> :ok = write_sidecar(project, media) :ok = ensure_thumbnails(project, media) :ok = Search.sync_media(media) {:ok, media} {:error, reason} -> _ = File.rm(destination) {:error, reason} end end @spec update_media(String.t(), attrs()) :: {:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_media(media_id, attrs) do case Repo.get(Media, media_id) do nil -> {:error, :not_found} media -> updates = %{} |> maybe_put(:title, attr(attrs, :title)) |> maybe_put(:alt, attr(attrs, :alt)) |> maybe_put(:caption, attr(attrs, :caption)) |> maybe_put(:author, attr(attrs, :author)) |> maybe_put(:language, attr(attrs, :language)) |> maybe_put(:tags, attr(attrs, :tags)) |> maybe_put(:width, attr(attrs, :width)) |> maybe_put(:height, attr(attrs, :height)) |> Map.put(:updated_at, Persistence.now_ms()) project = Projects.get_project!(media.project_id) case Repo.transaction(fn -> media |> Media.changeset(updates) |> Repo.update!() end) do {:ok, updated_media} -> :ok = write_sidecar(project, updated_media) :ok = Search.sync_media(updated_media) {:ok, updated_media} {:error, reason} -> {:error, reason} end end end @spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found} def delete_media(media_id) do case Repo.get(Media, media_id) do nil -> {:error, :not_found} media -> translations = Repo.all( from translation in Translation, where: translation.translation_for == ^media.id ) delete_file_if_present(media.project_id, media.file_path) delete_file_if_present(media.project_id, media.sidecar_path) delete_thumbnail_files(media.project_id, media) Enum.each(translations, fn translation -> delete_file_if_present( media.project_id, translation_sidecar_path(media, translation.language) ) Repo.delete!(translation) end) Repo.delete!(media) :ok = Search.delete_media(media.id) {:ok, :deleted} end end @spec upsert_media_translation(String.t(), String.t() | atom(), attrs()) :: {:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()} def upsert_media_translation(media_id, language, attrs) do case Repo.get(Media, media_id) do nil -> {:error, :not_found} media -> project = Projects.get_project!(media.project_id) now = Persistence.now_ms() translation = Repo.get_by(Translation, translation_for: media.id, language: language) || %Translation{id: Ecto.UUID.generate(), created_at: now} translation_attrs = %{ id: translation.id, project_id: media.project_id, translation_for: media.id, language: language, title: attr(attrs, :title), alt: attr(attrs, :alt), caption: attr(attrs, :caption), created_at: translation.created_at || now, updated_at: now } case Repo.transaction(fn -> translation |> Translation.changeset(translation_attrs) |> Repo.insert_or_update!() end) do {:ok, updated_translation} -> :ok = write_translation_sidecar(project, media, updated_translation) :ok = Search.sync_media(media.id) {:ok, updated_translation} {:error, reason} -> {:error, reason} end end end @spec delete_media_translation(String.t(), String.t() | atom()) :: {:ok, boolean()} | {:error, :not_found | term()} def delete_media_translation(media_id, language) do normalized_language = language |> to_string() |> String.trim() |> String.downcase() case Repo.get(Media, media_id) do nil -> {:error, :not_found} media -> case Repo.get_by(Translation, translation_for: media.id, language: normalized_language) do nil -> {:ok, false} translation -> project = Projects.get_project!(media.project_id) case Repo.transaction(fn -> Repo.delete!(translation) end) do {:ok, _deleted} -> delete_file_if_present( media.project_id, translation_sidecar_path(media, normalized_language) ) :ok = Search.sync_media(media) :ok = write_sidecar(project, media) {:ok, true} {:error, reason} -> {:error, reason} end end end end @spec replace_media_file(String.t(), String.t()) :: {:ok, Media.t() | nil} | {:error, :not_found | Ecto.Changeset.t() | term()} def replace_media_file(media_id, new_source_path) do case Repo.get(Media, media_id) do nil -> {:error, :not_found} media -> project = Projects.get_project!(media.project_id) destination = Path.join(Projects.project_data_dir(project), media.file_path) with {:ok, binary} <- File.read(new_source_path), {:ok, stat} <- File.stat(new_source_path) do checksum = Base.encode16(:crypto.hash(:md5, binary), case: :lower) if checksum == media.checksum do {:ok, nil} else mime_type = media.mime_type || detect_mime(media.original_name || media.filename) {width, height} = image_dimensions(new_source_path, mime_type) previous_destination_backup = destination <> ".bak" _ = File.rename(destination, previous_destination_backup) :ok = File.cp(new_source_path, destination) case Repo.transaction(fn -> media |> Media.changeset(%{ size: stat.size, width: width || media.width, height: height || media.height, checksum: checksum, updated_at: Persistence.now_ms() }) |> Repo.update!() end) do {:ok, updated_media} -> _ = File.rm(previous_destination_backup) :ok = write_sidecar(project, updated_media) :ok = ensure_thumbnails(project, updated_media) :ok = Search.sync_media(updated_media) {:ok, updated_media} {:error, reason} -> _ = File.rename(previous_destination_backup, destination) {:error, reason} end end end end end @spec list_media_translations(String.t()) :: [Translation.t()] def list_media_translations(media_id) when is_binary(media_id) do Repo.all( from translation in Translation, where: translation.translation_for == ^media_id, order_by: [asc: translation.language] ) end end