src/models/m_media.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2020 Marc Worrell
%% @doc Model for medium database

%% Copyright 2009-2020 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(m_media).
-author("Marc Worrell <marc@worrell.nl").

-behaviour(zotonic_model).

%% interface functions
-export([
    m_get/3,

    identify/2,
    get/2,
    get_by_filename/2,
    exists/2,
    depiction/2,
    depicts/2,
    delete/2,
    replace/3,
    duplicate/3,
    merge/3,
    insert_file/2,
    insert_file/3,
    insert_file/4,
    insert_medium/4,
    replace_file/3,
    replace_file/4,
    replace_file/5,
    replace_file/6,
    replace_medium/5,
    insert_url/2,
    insert_url/3,
    insert_url/4,
    replace_url/4,
    replace_url/5,
    reupload/2,
    save_preview_url/3,
    save_preview/4,
    make_preview_unique/3,
    is_unique_file/2,
    download_file/2,
    download_file/3,
    mime_to_category/1
]).

-include_lib("zotonic.hrl").
-include_lib("zotonic_file.hrl").

-define(MEDIA_MAX_LENGTH_PREVIEW, 10 * 1024 * 1024).
-define(MEDIA_MAX_LENGTH_DOWNLOAD, 500 * 1024 * 1024).
-define(MEDIA_TIMEOUT_DOWNLOAD, 60 * 1000).
-define(MEDIA_MAX_ROOTNAME_LENGTH, 80).

-type media_url() :: binary() | string().

-export_type([media_url/0]).

%% @doc Fetch the value for the key from a model source
-spec m_get( list(), zotonic_model:opt_msg(), z:context() ) -> zotonic_model:return().
m_get([ Id | Rest ], _Msg, Context) ->
    case m_rsc:rid(Id, Context) of
        undefined ->
            {error, enoent};
        RscId ->
            case z_acl:rsc_visible(RscId, Context) of
                true -> {ok, {get(RscId, Context), Rest}};
                false -> {error, eacces}
            end
    end;
m_get(_Vs, _Msg, _Context) ->
    {error, unknown_path}.


%% @doc Return the identification of a medium. Used by z_media_identify:identify()
-spec identify( m_rsc:resource_id(), z:context() ) -> {ok, z_media_identify:media_info()} | {error, term()}.
identify(Id, Context) when is_integer(Id) ->
    z_db:qmap_props_row(
        "select id, mime, width, height, orientation from medium where id = $1",
        [ Id ],
        [ {keys, binary} ],
        Context);
identify(ImageFile, Context) ->
    case z_media_archive:is_archived(ImageFile, Context) of
        true ->
            RelFile = z_media_archive:rel_archive(ImageFile, Context),
            identify_medium_filename(RelFile, Context);
        false ->
            identify_medium_filename(ImageFile, Context)
    end.

identify_medium_filename(MediumFilename, Context) ->
    z_db:qmap_props_row("
        select id, mime, width, height, orientation from medium where filename = $1",
        [ MediumFilename ],
        [ {keys, binary} ],
        Context).


%% @doc Check if a medium record exists. The argument should be undefined, or
%% a (textual) integer.
-spec exists( undefined | string() | binary() | integer(), z:context() ) -> boolean().
exists(undefined, _Context) ->
    false;
exists([C | _] = Name, Context) when is_integer(C) ->
    case z_utils:only_digits(Name) of
        true -> exists(list_to_integer(Name), Context);
        false -> false
    end;
exists(Id, Context) when is_binary(Id) ->
    exists(binary_to_list(Id), Context);
exists(Id, Context) ->
    case z_db:q1("select id from medium where id = $1", [Id], Context) of
        undefined -> false;
        _ -> true
    end.


%% @doc Get the medium record with the id
-spec get( m_rsc:resource() | undefined, z:context() ) -> z_media_identify:media_info() | undefined.
get(Id, Context) when is_integer(Id) ->
    F = fun() ->
        case z_db:qmap_props_row(
            "select * from medium where id = $1",
            [Id],
            [ {keys, binary} ],
            Context)
        of
            {ok, Map} -> Map;
            {error, nodb} -> undefined;
            {error, enoent} -> undefined
        end
    end,
    z_depcache:memo(F, {medium, Id}, ?WEEK, [Id], Context);
get(undefined, _Context) ->
    undefined;
get(RscId, Context) ->
    get(m_rsc:rid(RscId, Context), Context).


%% @doc Fetch a medium by filename
-spec get_by_filename( binary(), z:context() ) -> z_media_identify:media_info() | undefined.
get_by_filename(Filename, Context) ->
    case z_depcache:get({medium, Filename}, Context) of
        undefined ->
            Row = z_db:qmap_props_row("select * from medium where filename = $1", [Filename], Context),
            case Row of
                {ok, #{ <<"id">> := Id } = Medium} ->
                    z_depcache:set({medium, Filename}, Medium, ?HOUR, [Id], Context),
                    Medium;
                {error, enoent} ->
                    z_depcache:set({medium, Filename}, undefined, ?HOUR, Context),
                    undefined;
                {error, nodb} ->
                    z_depcache:set({medium, Filename}, undefined, ?HOUR, Context),
                    undefined
            end;
        {ok, Row} ->
            Row
    end.


%% @doc Get the medium record that depicts the resource id. "depiction" Predicates are preferred, if
%% they are missing then the attached medium record itself is returned.  We must be able to generate a preview
%% from the medium.
-spec depiction( m_rsc:resource_id(), z:context() ) -> map() | undefined.
depiction(Id, Context) ->
    try
        z_depcache:memo(
            fun() -> depiction([Id], [], Context) end,
            {depiction, Id},
            ?WEEK, [Id, {medium, Id}], Context)
    catch
        exit:{timeout, _} ->
            undefined
    end.

depiction([], _Visited, _Context) ->
    undefined;
depiction([Id | Ids], Visited, Context) ->
    case lists:member(Id, Visited) of
        true ->
            undefined;
        false ->
            Depictions = m_edge:objects(Id, depiction, Context) ++ [Id],
            case depiction(Depictions, [Id | Visited], Context) of
                undefined ->
                    case get(Id, Context) of
                        undefined ->
                            depiction(Ids, [Id | Visited], Context);
                        Media ->
                            Media
                    end;
                Media when is_map(Media) ->
                    Media
            end
    end.


%% @doc Return the list of resources that is depicted by the medium (excluding the rsc itself)
-spec depicts( m_rsc:resource_id(), z:context() ) -> list( m_rsc:resource_id() ).
depicts(Id, Context) ->
    m_edge:subjects(Id, depiction, Context).


%% @doc Delete the medium at the id.  The file is queued for later deletion.
-spec delete( m_rsc:resource_id(), z:context() ) -> ok  | {error, term()}.
delete(Id, Context) ->
    case z_acl:rsc_editable(Id, Context) of
        true ->
            Depicts = depicts(Id, Context),
            medium_delete(Id, Context),
            [z_depcache:flush(DepictId, Context) || DepictId <- Depicts],
            m_rsc:touch(Id, Context),
            z_notifier:notify(#media_replace_file{id = Id, medium = []}, Context),
            z_mqtt:publish(
                [ <<"model">>, <<"media">>, <<"event">>, Id, <<"delete">> ],
                #{ id => Id },
                Context),
            ok;
        false ->
            {error, eacces}
    end.


%% @doc Replace or insert a medium record for the page.  This is useful for non-file related media.
%% Resets all non mentioned attributes.
-spec replace( m_rsc:resource_id(), map(), z:context() ) -> ok  | {error, term()}.
replace(Id, Props, Context) when is_list(Props) ->
    {ok, Map} = z_props:from_list(Props),
    replace(Id, Map, Context);
replace(Id, Props, Context) ->
    Mime = maps:get(<<"mime">>, Props, undefined),
    Size = maps:get(<<"size">>, Props, 1),
    case z_acl:rsc_editable(Id, Context) andalso
        z_acl:is_allowed(insert, #acl_media{mime = Mime, size = Size}, Context)
    of
        true ->
            Depicts = depicts(Id, Context),
            #media_upload_preprocess{ medium = Props1 } = set_av_flag(
                #media_upload_preprocess{
                    medium = Props,
                    mime = Mime
                },
                Context),
            F = fun(Ctx) ->
                {ok, _} = medium_delete(Id, Ctx),
                {ok, Id} = medium_insert(Id, Props1#{ <<"id">> => Id }, Ctx)
            end,
            case z_db:transaction(F, Context) of
                {ok, _} ->
                    [z_depcache:flush(DepictId, Context) || DepictId <- Depicts],
                    z_depcache:flush(Id, Context),
                    Medium = get(Id, Context),
                    z_notifier:notify(#media_replace_file{id = Id, medium = Medium}, Context),
                    z_mqtt:publish(
                        [ <<"model">>, <<"media">>, <<"event">>, Id, <<"update">> ],
                        mqtt_event_info(Medium),
                        Context),
                    ok;
                {rollback, {Error, _Trace}} ->
                    {error, Error}
            end;
        false ->
            {error, eacces}
    end.


%% @doc Move a medium between resources, iff the destination doesn't have an associated medium.
%%      This is called when merging two resources (and the FromId is subsequently deleted).
merge(WinnerId, LoserId, Context) ->
    case z_acl:rsc_editable(LoserId, Context) andalso z_acl:rsc_editable(WinnerId, Context) of
        true ->
            case z_db:q1("select count(*) from medium where id = $1", [WinnerId], Context) of
                1 ->
                    ok;
                0 ->
                    case z_db:q1("select count(*) from medium where id = $1", [LoserId], Context) of
                        0 ->
                            ok;
                        1 ->
                            Depicts = depicts(LoserId, Context),
                            1 = z_db:q("update medium set id = $1 where id = $2",
                                [ WinnerId, LoserId ],
                                Context),
                            [ z_depcache:flush(DepictId, Context) || DepictId <- Depicts ],
                            z_depcache:flush(LoserId, Context),
                            z_depcache:flush(WinnerId, Context),
                            ok
                    end
            end;
        false ->
            {error, eacces}
    end.

%% @doc Duplicate the media item from the id to the new-id. Called by m_rsc:duplicate/3
-spec duplicate( m_rsc:resource(), m_rsc:resource(), z:context() ) -> ok | {error, term()}.
duplicate(FromId, ToId, Context) ->
    FromId1 = m_rsc:rid(FromId, Context),
    ToId1 = m_rsc:rid(ToId, Context),
    case z_db:qmap_props_row("select * from medium where id = $1", [FromId1], Context) of
        {ok, Ms} ->
            {ok, Ms1} = maybe_duplicate_file(Ms, Context),
            {ok, Ms2} = maybe_duplicate_preview(Ms1, Context),
            Ms3 = Ms2#{
                <<"id">> => ToId1
            },
            case medium_insert(ToId1, Ms3, Context) of
                {ok, _} -> ok;
                {error, _} = Error -> Error
            end;
        {error, _} = Error ->
            Error
    end.

maybe_duplicate_file(#{ <<"filename">> := <<>> } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_file(#{ <<"filename">> := undefined } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_file(#{ <<"filename">> := _, <<"is_deletable_file">> := false } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_file(#{ <<"filename">> := Filename, <<"is_deletable_file">> := true } = Ms, Context) ->
    {ok, NewFile} = duplicate_file(archive, Filename, Context),
    RootName = z_string:truncatechars(
        filename:rootname(filename:basename(NewFile)),
        ?MEDIA_MAX_ROOTNAME_LENGTH),
    Ms2 = Ms#{
        <<"filename">> => NewFile,
        <<"rootname">> => RootName,
        <<"is_deletable_file">> => true
    },
    {ok, Ms2}.

maybe_duplicate_preview(#{ <<"preview_filename">> := <<>> } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_preview(#{ <<"preview_filename">> := undefined } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_preview(#{ <<"preview_filename">> := _, <<"is_deletable_preview">> := false  } = Ms, _Context) ->
    {ok, Ms};
maybe_duplicate_preview(#{ <<"preview_filename">> := Filename, <<"is_deletable_preview">> := true } = Ms, Context) ->
    case duplicate_file(preview, Filename, Context) of
        {ok, NewFile} ->
            Ms1 = Ms#{
                <<"preview_filename">> => NewFile,
                <<"is_deletable_preview">> => true
            },
            {ok, Ms1};
        {error, Reason} ->
            ?LOG_ERROR(#{
                text => <<"Error duplicating preview">>,
                in => zotonic_core,
                result => error,
                reason => Reason,
                filename => Filename
            }),
            Ms1 = maps:remove(<<"preview_filename">>, Ms),
            Ms2 = maps:remove(<<"is_deletable_preview">>, Ms1),
            {ok, Ms2}
    end.


duplicate_file(Type, Filename, Context) ->
    case z_file_request:lookup_file(Filename, Context) of
        {ok, FileInfo} ->
            {ok, File} = z_file_request:content_file(FileInfo, Context),
            {ok, z_media_archive:archive_copy(Type, File, filename:basename(Filename), Context)};
        {error, _} = Error ->
            Error
    end.

%% @doc Make a new resource for the file, when the file is not in the archive
%% dir then a copy is made in the archive dir
-spec insert_file(file:filename_all() | #upload{}, z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
insert_file(File, Context) ->
    insert_file(File, #{}, [], Context).

-spec insert_file(file:filename_all() | #upload{}, m_rsc:props_all(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
insert_file(File, RscProps, Context) ->
    insert_file(File, RscProps, [], Context).

-spec insert_file(file:filename_all() | #upload{}, m_rsc:props_all(), list(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
insert_file(File, RscProps, Options, Context) when is_list(RscProps) ->
    {ok, PropsMap} = z_props:from_list(RscProps),
    insert_file(File, PropsMap, Options, Context);
insert_file(#upload{ data = Data, tmpfile = undefined } = Upload, RscProps, Options, Context) when Data =/= undefined ->
    TmpFile = z_tempfile:new(),
    case file:write_file(TmpFile, Data) of
        ok ->
            Result = insert_file(Upload#upload{ tmpfile = TmpFile }, RscProps, Options, Context),
            file:delete(TmpFile),
            Result;
        {error, Reason} = Error ->
            ?LOG_ERROR(#{
                text => <<"Could not write temporary file">>,
                in => zotonic_core,
                result => error,
                reason => Reason,
                size => iolist_size(Data),
                filename => TmpFile
            }),
            file:delete(TmpFile),
            Error
    end;
insert_file(#upload{filename = OriginalFilename, tmpfile = TmpFile}, RscProps, Options, Context) ->
    case z_tempfile:is_tempfile(TmpFile) of
        true ->
            RscProps1 = RscProps#{
                <<"original_filename">> => OriginalFilename
            },
            MediaProps = #{
                <<"original_filename">> => OriginalFilename
            },
            MediaProps1 = add_medium_info(TmpFile, OriginalFilename, MediaProps, Context),
            insert_file(TmpFile, RscProps1, MediaProps1, Options, Context);
        false ->
            {error, upload_not_tempfile}
    end;
insert_file(File, RscProps, Options, Context) ->
    OriginalFilename = maps:get(<<"original_filename">>, RscProps, File),
    MediaProps = #{
        <<"original_filename">> => OriginalFilename
    },
    MediaProps1 = add_medium_info(File, OriginalFilename, MediaProps, Context),
    insert_file(File, RscProps, MediaProps1, Options, Context).

insert_file(File, RscProps, MediaProps, Options, Context) ->
    Mime = maps:get(<<"mime">>, MediaProps, undefined),
    case z_acl:is_allowed(insert, #acl_rsc{category = mime_to_category(Mime), props = RscProps}, Context) andalso
        z_acl:is_allowed(insert, #acl_media{mime = Mime, size = filelib:file_size(File)}, Context) of
        true ->
            insert_file_mime_ok(File, RscProps, MediaProps, Options, Context);
        false ->
            {error, file_not_allowed}
    end.

%% @doc Insert a medium, together with rsc props and an optional preview_url. This is used for importing media.
insert_medium(Medium, RscProps, Options, Context) ->
    update_medium_1(insert_rsc, Medium, RscProps, Options, Context).

replace_medium(Medium, RscId, RscProps, Options, Context) ->
    case z_acl:rsc_editable(RscId, Context) of
        true -> update_medium_1(RscId, Medium, RscProps, Options, Context);
        false -> {error, eacces}
    end.

update_medium_1(RscId, Medium, #{ <<"category">> := Cat } = RscProps, Options, Context) ->
    RscProps1 = maps:remove(<<"category">>, RscProps),
    RscProps2 = RscProps1#{ <<"category_id">> => Cat },
    update_medium_1(RscId, Medium, RscProps2, Options, Context);
update_medium_1(RscId, Medium, RscProps, Options, Context) ->
    case is_update_medium_allowed(RscId, Medium, RscProps, Context) of
        true ->
            #media_upload_preprocess{ medium = Medium1 } = set_av_flag(
                #media_upload_preprocess{
                    medium = Medium,
                    mime = maps:get(<<"mime">>, Medium)
                }, Context),
            case replace_file_acl_ok(undefined, RscId, RscProps, Medium1, Options, Context) of
                {ok, NewRscId} ->
                    case proplists:get_value(preview_url, Options) of
                        None when None =:= undefined; None =:= <<>>; None =:= [] ->
                            nop;
                        PreviewUrl ->
                            save_preview_url(NewRscId, PreviewUrl, Context)
                    end,
                    {ok, NewRscId};
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, file_not_allowed}
    end.

is_update_medium_allowed(insert_rsc, #{ <<"mime">> := Mime }, #{ <<"category_id">> := Category } = RscProps, Context) ->
    z_acl:is_allowed(insert, #acl_rsc{category = Category, props = RscProps}, Context)
    andalso z_acl:is_allowed(insert, #acl_media{mime=Mime, size=0}, Context);
is_update_medium_allowed(insert_rsc, _Medium, _RscProps, _Context) ->
    % No category - fail
    {error, no_category};
is_update_medium_allowed(RscId, #{ <<"mime">> := Mime }, #{ <<"category_id">> := Category } = RscProps, Context) ->
    % Changing the category, check insert rights for the new category
    z_acl:is_allowed(insert, #acl_rsc{id = RscId, category = Category, props = RscProps}, Context)
    andalso z_acl:is_allowed(insert, #acl_media{mime=Mime, size=0}, Context);
is_update_medium_allowed(_RscId, #{ <<"mime">> := Mime }, _RscProps, Context) ->
    % Update check was already done, only check the Mime type
    z_acl:is_allowed(insert, #acl_media{mime=Mime, size=0}, Context).



%% @doc Make a new resource for the file based on a URL.
-spec insert_url(media_url(), z:context()) -> {ok, pos_integer()} | {error, term()}.
insert_url(Url, Context) ->
    insert_url(Url, #{}, [], Context).

-spec insert_url(media_url(), m_rsc:props_all(), z:context()) -> {ok, pos_integer()} | {error, term()}.
insert_url(Url, RscProps, Context) ->
    insert_url(Url, RscProps, [], Context).

-spec insert_url(media_url(), m_rsc:props_all(), list(), z:context()) -> {ok, pos_integer()} | {error, term()}.
insert_url(Url, RscProps, Options, Context) when is_list(RscProps) ->
    {ok, PropsMap} = z_props:from_list(RscProps),
    insert_url(Url, PropsMap, Options, Context);
insert_url(Url, RscProps, Options, Context) ->
    case download_file(Url, Options, Context) of
        {ok, TmpFile, Filename} ->
            RscProps1 = RscProps#{
                <<"original_filename">> => Filename
            },
            Result = insert_file(TmpFile, RscProps1, Options, Context),
            file:delete(TmpFile),
            Result;
        {error, Reason} ->
            {error, Reason}
    end.

%% Perform the resource management around inserting a file. The ACL is already checked for the mime type.
%% Runs the final insert inside a transaction so that we can rollback.
insert_file_mime_ok(File, RscProps, MediaProps, Options, Context) ->
    IsPublished = z_convert:to_bool( maps:get(<<"is_published">>, RscProps, true) ),
    RscProps1 = RscProps#{
        <<"is_published">> => IsPublished
    },
    replace_file_mime_ok(File, insert_rsc, RscProps1, MediaProps, Options, Context).

filename_basename(undefined) -> <<>>;
filename_basename(Filename) ->
    F1 = z_convert:to_binary(Filename),
    F2 = lists:last(binary:split(F1, <<"/">>, [global])),
    lists:last(binary:split(F2, <<"\\">>, [global])).

%% @doc Replaces a medium file, when the file is not in archive then a copy is
%% made in the archive. When the resource is in the media category, then the
%% category is adapted depending on the mime type of the uploaded file.
replace_file(File, RscId, Context) ->
    replace_file(File, RscId, #{}, #{}, [], Context).

replace_file(File, RscId, RscProps, Context) ->
    replace_file(File, RscId, RscProps, #{}, [], Context).

replace_file(File, RscId, RscProps, Opts, Context) ->
    replace_file(File, RscId, RscProps, #{}, Opts, Context).

replace_file(File, RscId, RscProps, MediaInfo, Opts, Context) when is_list(RscProps) ->
    {ok, RscMap} = z_props:from_list(RscProps),
    replace_file(File, RscId, RscMap, MediaInfo, Opts, Context);
replace_file(File, RscId, RscProps, MediaInfo, Opts, Context) when is_list(MediaInfo) ->
    {ok, MediaInfoMap} = z_props:from_list(MediaInfo),
    replace_file(File, RscId, RscProps, MediaInfoMap, Opts, Context);
replace_file(
        #upload{filename = OriginalFilename, data = Data, tmpfile = undefined},
        RscId, RscProps, MInfo, Opts, Context) when Data =/= undefined ->
    TmpFile = z_tempfile:new(),
    ok = file:write_file(TmpFile, Data),
    replace_file(#upload{filename = OriginalFilename, tmpfile = TmpFile}, RscId, RscProps, MInfo, Opts, Context);
replace_file(#upload{filename = OriginalFilename, tmpfile = TmpFile}, RscId, RscProps, MInfo, Opts, Context) ->
    case z_tempfile:is_tempfile(TmpFile) of
        true ->
            MInfo1 = MInfo#{
                <<"original_filename">> => OriginalFilename
            },
            MediaProps = add_medium_info(TmpFile, OriginalFilename, MInfo1, Context),
            RscProps1 = RscProps#{
                <<"original_filename">> => OriginalFilename
            },
            replace_file_mime_check(TmpFile, RscId, RscProps1, MediaProps, Opts, Context);
        false ->
            {error, upload_not_tempfile}
    end;
replace_file(File, RscId, RscProps, MInfo, Opts, Context) ->
    OriginalFilename = maps:get(<<"original_filename">>, RscProps, File),
    MInfo1 = MInfo#{
        <<"original_filename">> => OriginalFilename
    },
    MediaProps = add_medium_info(File, OriginalFilename, MInfo1, Context),
    replace_file_mime_check(File, RscId, RscProps, MediaProps, Opts, Context).

replace_file_mime_check(File, RscId, RscProps, MediaProps, Opts, Context) ->
    Mime = maps:get(<<"mime">>, MediaProps, undefined),
    case z_acl:is_allowed(insert, #acl_media{ mime = Mime, size = filelib:file_size(File) }, Context) of
        true ->
            replace_file_mime_ok(File, RscId, RscProps, MediaProps, Opts, Context);
        false ->
            {error, file_not_allowed}
    end.

%% @doc Check the ACL for the category/content-group combination.
replace_file_mime_ok(File, insert_rsc, RscProps, MediaProps, Opts, Context) ->
    % The category/content-group is also checked during the m_rsc:insert/2 call.
    % This is an early check to prevent preprocessing files for resources that
    % are not allowed to be created.
    CatId = case maps:find(<<"category_id">>, RscProps) of
        {ok, CId} when CId =/= undefined ->
            CId;
        error ->
            case maps:find(<<"category">>, RscProps) of
                {ok, CName} when CName =/= undefined ->
                    m_rsc:rid(CName, Context);
                error ->
                    Mime = maps:get(<<"mime">>, MediaProps, undefined),
                    m_rsc:rid(mime_to_category(Mime), Context)
            end
    end,
    case m_category:id_to_name(CatId, Context) of
        undefined ->
            {error, eacces};
        Cat ->
            case z_acl:is_allowed(insert, #acl_rsc{ category = Cat, props = RscProps }, Context) of
                true ->
                    replace_file_acl_ok(File, insert_rsc, RscProps, MediaProps, Opts, Context);
                false ->
                    {error, eacces}
            end
    end;
replace_file_mime_ok(File, RscId, RscProps, MediaProps, Opts, Context) ->
    case z_acl:rsc_editable(RscId, Context) of
        true ->
            replace_file_acl_ok(File, RscId, RscProps, MediaProps, Opts, Context);
        false ->
            {error, eacces}
    end.

%% @doc Preprocess the data, examples are virus scanning and video preprocessing
replace_file_acl_ok(File, RscId, RscProps, Medium, Opts, Context) ->
    Mime = maps:get(<<"mime">>, Medium, undefined),
    OriginalFilename = maps:get(
        <<"original_filename">>,
        RscProps,
        maps:get(<<"original_filename">>, Medium, File)),
    PreProc = #media_upload_preprocess{
        id = RscId,
        mime = Mime,
        file = File,
        original_filename = OriginalFilename,
        medium = Medium
    },
    case notify_first_preproc(PreProc, true, Context) of
        {ok, PreProc1} ->
            PreProc2 = set_av_flag(PreProc1, Context),
            replace_file_sanitize(RscId, PreProc2, RscProps, Opts, Context);
        {error, _} = Error ->
            Error
    end.

%% @doc Set a flag that there was an av scan. This needs to be more generic.
set_av_flag( #media_upload_preprocess{ medium = Medium } = PreProc, Context ) ->
    IsAvScanned = not maps:get(<<"is_av_sizelimit">>, Medium, false)
        andalso lists:member(antivirus, z_module_manager:get_provided(Context)),
    Medium1 = Medium#{
        <<"is_av_scanned">> => IsAvScanned
    },
    PreProc#media_upload_preprocess{ medium = Medium1 }.

notify_first_preproc(PreProc, IsFirstTry, Context) ->
    case z_notifier:first(PreProc, Context) of
        undefined ->
            {ok, PreProc};
        #media_upload_preprocess{} = MappedPreProc ->
            {ok, MappedPreProc};
        {error, av_sizelimit} when IsFirstTry ->
            Medium1 = (PreProc#media_upload_preprocess.medium)#{
                <<"is_av_sizelimit">> => true
            },
            PreProc1 = PreProc#media_upload_preprocess{ medium = Medium1 },
            notify_first_preproc(PreProc1, false, Context);
        {error, _} = Error ->
            Error
    end.

%% @doc Clean up the uploaded data, removing bits that might be harmful.
replace_file_sanitize(RscId, PreProc, Props, Opts, Context) ->
    PreProc1 = z_media_sanitize:sanitize(PreProc, Context),
    replace_file_db(RscId, PreProc1, Props, Opts, Context).

-spec replace_file_db( m_rsc:resource_id() | insert_rsc, #media_upload_preprocess{}, map(), list(), z:context() ) ->
    {ok, m_rsc:resource_id()} | {error, term()}.
replace_file_db(RscId, PreProc, Props, Opts, Context) ->
    SafeRootName = z_string:truncate(
        z_string:to_rootname(PreProc#media_upload_preprocess.original_filename),
        ?MEDIA_MAX_ROOTNAME_LENGTH),
    PreferExtension = z_convert:to_binary(
        filename:extension(PreProc#media_upload_preprocess.original_filename)),
    Mime = z_convert:to_binary(PreProc#media_upload_preprocess.mime),
    SafeFilename = iolist_to_binary([
        SafeRootName, z_media_identify:extension(Mime, PreferExtension, Context)
    ]),
    ArchiveFile = case PreProc#media_upload_preprocess.file of
        undefined -> undefined;
        UploadFile -> z_media_archive:archive_copy_opt(UploadFile, SafeFilename, Context)
    end,
    RootName = case ArchiveFile of
        undefined -> undefined;
        _ -> filename:rootname(filename:basename(ArchiveFile))
    end,
    Medium0 = #{
        <<"mime">> => Mime,
        <<"filename">> => ArchiveFile,
        <<"rootname">> => RootName,
        <<"is_deletable_file">> => is_deletable_file(PreProc#media_upload_preprocess.file, Context)
    },
    Medium = maps:merge(Medium0, PreProc#media_upload_preprocess.medium),
    Medium1 = z_notifier:foldl(
        #media_upload_props{
            id = RscId,
            mime = Mime,
            archive_file = ArchiveFile,
            options = Opts
        },
        Medium,
        Context),

    PropsM = z_notifier:foldl(
        #media_upload_rsc_props{
            id = RscId,
            mime = Mime,
            archive_file = ArchiveFile,
            options = Opts,
            medium = Medium1
        },
        Props,
        Context),

    PropsM1 = case RscId =:= insert_rsc andalso z_utils:is_empty(maps:get(<<"title">>, PropsM, <<>>)) of
        true ->
            OriginalFilename = maps:get(<<"original_filename">>, Medium1, undefined),
            PropsM#{
                <<"title">> => filename_basename(OriginalFilename)
            };
        false ->
            PropsM
    end,

    F = fun(Ctx) ->
        %% If the resource is in the media category, then move it to the correct sub-category depending
        %% on the mime type of the uploaded file.
        PropsCat = case maps:is_key(<<"category">>, PropsM1)
            orelse maps:is_key(<<"category_id">>, PropsM1)
        of
            true ->
                PropsM1;
            false ->
                PropsM1#{
                    <<"category_id">> => mime_to_category(Mime)
                }
        end,
        {ok, Id} = case RscId of
            insert_rsc ->
                m_rsc_update:insert(PropsCat, Opts, Ctx);
            _ ->
                case rsc_is_media_cat(RscId, Context) of
                    true ->
                        {ok, RscId} = m_rsc_update:update(RscId, PropsCat, Opts, Ctx);
                    false when map_size(Props) > 0 ->
                        {ok, RscId} = m_rsc_update:update(RscId, Props, Opts, Ctx);
                    false ->
                        ok
                end,
                medium_delete(RscId, Ctx),
                {ok, RscId}
        end,
        Medium2 = Medium1#{ <<"id">> => Id },
        case medium_insert(Id, Medium2, Ctx) of
            {ok, _MediaId} ->
                {ok, Id};
            Error ->
                % TODO: remove the created
                Error
        end
    end,

    case z_db:transaction(F, Context) of
        {ok, Id} ->
            Depicts = depicts(Id, Context),
            [z_depcache:flush(DepictId, Context) || DepictId <- Depicts],
            z_depcache:flush(Id, Context),

            %% Flush categories
            CatList = m_rsc:is_a(Id, Context),
            lists:foreach(
                fun(Cat) ->
                    z_depcache:flush(Cat, Context)
                end,
                CatList),

            _ = m_rsc:get(Id, Context), %% Prevent side effect that empty things are cached?

            % Run possible post insertion function.
            case PreProc#media_upload_preprocess.post_insert_fun of
                undefined -> ok;
                PostFun when is_function(PostFun, 3) ->
                    PostFun(Id, Medium1, Context)
            end,

            %% Pass the medium record along in the notification; this also fills the depcache (side effect).
            NewMedium = get(Id, Context),
            z_notifier:notify(#media_replace_file{id = Id, medium = NewMedium}, Context),
            z_mqtt:publish(
                [ <<"model">>, <<"media">>, <<"event">>, Id, <<"update">> ],
                mqtt_event_info(NewMedium),
                Context),
            {ok, Id};
        {rollback, {{error, Reason}, _StackTrace}} ->
            {error, Reason}
    end.

is_deletable_file(undefined, _Context) ->
    false;
is_deletable_file(File, Context) ->
    not z_media_archive:is_archived(File, Context).

replace_url(Url, RscId, RscProps, Context) ->
    replace_url(Url, RscId, RscProps, [], Context).

replace_url(Url, RscId, RscProps, Options, Context) when is_list(RscProps) ->
    {ok, PropsMap} = z_props:from_list(RscProps),
    replace_url(Url, RscId, PropsMap, Options, Context);
replace_url(Url, RscId, RscProps, Options, Context) ->
    case z_acl:rsc_editable(RscId, Context) of
        true ->
            case download_file(Url, Options, Context) of
                {ok, File, Filename} ->
                    RscProps1 = case maps:get(<<"original_filename">>, RscProps, undefined) of
                        F when is_binary(F), F =/= <<>> ->
                            RscProps;
                        _ ->
                            RscProps#{
                                <<"original_filename">> => Filename
                            }
                    end,
                    Result = replace_file(File, RscId, RscProps1, Options, Context),
                    file:delete(File),
                    Result;
                {error, E} ->
                    {error, E}
            end;
        false ->
            {error, eacces}
    end.

%% @doc Re-upload a file so that identify and previews are regenerated.
-spec reupload( m_rsc:resource_id(), z:context() ) -> {ok, m_rsc:resource_id()} | {error, term()}.
reupload(Id, Context) ->
    case z_acl:rsc_editable(Id, Context) of
        true ->
            case get(Id, Context) of
                undefined ->
                    {error, enoent};
                Medium ->
                    case maps:get(<<"size">>, Medium, 0) of
                        undefined -> {error, nofile};
                        0 -> {error, nofile};
                        _ -> reupload_1(Id, Medium, Context)
                    end
            end;
        false ->
            {error, eacces}
    end.

reupload_1(Id, #{ <<"filename">> := Filename } = Medium, Context) when is_binary(Filename), Filename =/= <<>> ->
    reupload_2(Id, Medium, Filename, z_file_request:lookup_file(Filename, Context), Context);
reupload_1(_Id, _Medium, _Context) ->
    {error, nofile}.

reupload_2(Id, Medium, Filename, {ok, #z_file_info{} = FInfo}, Context) ->
    case z_file_request:content_file(FInfo, Context) of
        {ok, MediaFile} ->
            TmpFile = z_tempfile:new(),
            % Copy file to temp file
            Result = case file:copy(MediaFile, TmpFile) of
                {ok, _} ->
                    OrgFilename = case maps:get(<<"original_filename">>, Medium, undefined) of
                        undefined -> filename:basename(Filename);
                        <<>> -> filename:basename(Filename);
                        Fn -> Fn
                    end,
                    Upload = #upload{
                        filename = OrgFilename,
                        tmpfile = TmpFile
                    },
                    replace_file(Upload, Id, Context);
                {error, _} = Error ->
                    Error
            end,
            file:delete(TmpFile),
            Result;
        {error, _} = Error ->
            Error
    end;
reupload_2(_Id, _Medium, _Filename, {error, _} = Error, _Context) ->
    Error.


-spec rsc_is_media_cat( m_resource:id(), z:context() ) -> boolean().
rsc_is_media_cat(Id, Context) ->
    case z_db:q1("select c.name from rsc c join rsc r on r.category_id = c.id where r.id = $1", [Id],
        Context) of
        <<"media">> -> true;
        <<"image">> -> true;
        <<"audio">> -> true;
        <<"video">> -> true;
        <<"document">> -> true;
        _ -> false
    end.

-spec mime_to_category( string() | binary() ) -> image | video | audio | document.
mime_to_category(Mime) ->
    case Mime of
        <<"image/", _/binary>> -> image;
        <<"video/", _/binary>> -> video;
        <<"text/html-video-embed">> -> video;
        <<"audio/", _/binary>> -> audio;
        <<"application/", _/binary>> -> document;
        <<"text/", _/binary>> -> document;

        "image/" ++ _ -> image;
        "video/" ++ _ -> video;
        "text/html-video-embed" -> video;
        "audio/" ++ _ -> audio;
        "application/" ++ _ -> document;
        "text/" ++ _ -> document;

        _ -> media
    end.


%% @doc Download a file from a http or data url.
download_file(Url, Context) ->
    download_file(Url, [], Context).

download_file(Url, Options, Context) ->
    File = z_tempfile:new(),
    {ok, Device} = file:open(File, [write]),
    FetchOptions = proplists:get_value(fetch_options, Options, []),
    % Backwards compatible: also allow max_length and timeout as direct options.
    OptionsAll = Options ++ FetchOptions,
    MaxLength = proplists:get_value(max_length, OptionsAll, ?MEDIA_MAX_LENGTH_DOWNLOAD),
    Timeout = proplists:get_value(timeout, OptionsAll, ?MEDIA_TIMEOUT_DOWNLOAD),
    FetchOptions1 = [
        {max_length, MaxLength},
        {timeout, Timeout},
        {device, Device}
        | proplists:delete(max_length,
            proplists:delete(timeout,
                proplists:delete(device, FetchOptions)))
    ],
    case z_fetch:fetch_partial(Url, FetchOptions1, Context) of
        {ok, {_FinalUrl, Hs, Length, _Data}} when Length < MaxLength ->
            file:close(Device),
            {ok, File, filename(Url, Hs)};
        {ok, {_FinalUrl, _Hs, Length, _Data}} when Length >= MaxLength ->
            file:close(Device),
            file:delete(File),
            {error, file_too_large};
        {ok, _Other} ->
            file:close(Device),
            file:delete(File),
            {error, download_failed};
        {error, _} = Error ->
            file:close(Device),
            file:delete(File),
            Error
    end.

filename(Url, Hs) ->
    case z_url_metadata:filename(Url, Hs) of
        undefined ->
            {CT, _CTOpts} = content_type(Hs),
            mime2filename(CT);
        FN ->
            FN
    end.

content_type(Hs) ->
    case proplists:get_value("content-type", Hs) of
        undefined ->
            {<<"application/octet-stream">>, []};
        CT ->
            {Mime, Options} = mochiweb_util:parse_header(CT),
            {z_convert:to_binary(Mime), Options}
    end.

mime2filename(Mime) ->
    iolist_to_binary([filebase(Mime), z_media_identify:extension(Mime)]).

filebase(<<"video/", _/binary>>) -> <<"video">>;
filebase(<<"image/", _/binary>>) -> <<"image">>;
filebase(<<"audio/", _/binary>>) -> <<"audio">>;
filebase(<<"media/", _/binary>>) -> <<"media">>;
filebase(_) -> <<"document">>.

%% @doc Fetch the medium information of the file, if they are not set in the Props
add_medium_info(File, OriginalFilename, MediaProps, Context) ->
    PropsSize = case maps:get(<<"size">>, MediaProps, undefined) of
        undefined ->
            MediaProps#{
                <<"size">> => filelib:file_size(File)
            };
        _ ->
            MediaProps
    end,
    PropsMime = case maps:get(<<"mime">>, PropsSize, undefined) of
        undefined ->
            case z_media_identify:identify_file(File, OriginalFilename, Context) of
                {ok, MediaInfo} ->
                    maps:merge(MediaInfo, PropsSize);
                {error, _Reason} ->
                    PropsSize
            end;
        _ ->
            PropsSize
    end,
    PropsMime.


%% @doc Save a new file from a preview_url as the preview of a medium
save_preview_url(RscId, Url, Context) ->
    case download_file(Url, [{max_length, ?MEDIA_MAX_LENGTH_PREVIEW}], Context) of
        {ok, TmpFile, Filename} ->
            case z_media_identify:identify_file(TmpFile, Filename, Context) of
                {ok, #{ <<"mime">> := <<"image/", _/binary>> } = MediaInfo} ->
                    try
                        Mime = maps:get(<<"mime">>, MediaInfo),
                        Width = maps:get(<<"width">>, MediaInfo),
                        Height = maps:get(<<"height">>, MediaInfo),

                        FileUnique = make_preview_unique(RscId, z_media_identify:extension(Mime), Context),
                        FileUniqueAbs = z_media_archive:abspath(FileUnique, Context),
                        ok = z_filelib:ensure_dir(FileUniqueAbs),
                        case file:rename(TmpFile, FileUniqueAbs) of
                            %% cross-fs rename is not supported by erlang, so copy and delete the file
                            {error, exdev} ->
                                {ok, _BytesCopied} = file:copy(TmpFile, FileUniqueAbs),
                                ok = file:delete(TmpFile);
                            ok ->
                                ok
                        end,
                        UpdateProps = #{
                            <<"preview_filename">> => FileUnique,
                            <<"preview_width">> => Width,
                            <<"preview_height">> => Height,
                            <<"is_deletable_preview">> => true
                        },
                        {ok, 1} = z_db:update(medium, RscId, UpdateProps, Context),
                        z_depcache:flush({medium, RscId}, Context),
                        {ok, FileUnique}
                    catch
                        Type:Error ->
                            ?LOG_WARNING(#{
                                text => <<"Error importing preview">>,
                                in => zotonic_core,
                                result => Type,
                                reason => Error,
                                rsc_id => RscId,
                                url => Url,
                                mediainfo => MediaInfo
                            }),
                            file:delete(TmpFile),
                            {error, Error}
                    end;
                {ok, MediaInfo} ->
                    Mime = maps:get(<<"mime">>, MediaInfo, undefined),
                    ?LOG_WARNING(#{
                        text => <<"Error importing preview">>,
                        in => zotonic_core,
                        result => error,
                        reason => no_image,
                        rsc_id => RscId,
                        url => Url,
                        mime => Mime
                    }),
                    {error, no_image};
                {error, _} = Error ->
                    Error
            end;
        {error, _} = Error ->
            Error
    end.

%% @doc Save a preview for a medium record. The data is saved to a file in the archive directory.
-spec save_preview( m_rsc:resource_id(), iodata(), binary()|string(), z:context() ) ->
    {ok, file:filename_all()} | {error, eacces | term()}.
save_preview(RscId, Data, Mime, Context) ->
    case z_acl:rsc_editable(RscId, Context) of
        true ->
            FileUnique = make_preview_unique(RscId, z_media_identify:extension(Mime), Context),
            FileUniqueAbs = z_media_archive:abspath(FileUnique, Context),
            ok = z_filelib:ensure_dir(FileUniqueAbs),
            ok = file:write_file(FileUniqueAbs, Data),

            try
                {ok, MediaInfo} = z_media_identify:identify(FileUniqueAbs, Context),
                Width = maps:get(<<"width">>, MediaInfo),
                Height = maps:get(<<"height">>, MediaInfo),

                UpdateProps = #{
                    <<"preview_filename">> => FileUnique,
                    <<"preview_width">> => Width,
                    <<"preview_height">> => Height,
                    <<"is_deletable_preview">> => true
                },
                {ok, 1} = z_db:update(medium, RscId, UpdateProps, Context),
                z_depcache:flush({medium, RscId}, Context),
                {ok, FileUnique}
            catch
                throw:{error, _} = Error ->
                    file:delete(FileUniqueAbs),
                    Error
            end;
        false ->
            {error, eacces}
    end.

-spec make_preview_unique(integer()|insert_rsc, binary(), z:context()) -> file:filename().
make_preview_unique(RscId, Extension, Context) ->
    Basename = iolist_to_binary([id_to_list(RscId), $-, z_ids:identifier(16), Extension]),
    Filename = filename:join([
        "preview",
        z_ids:identifier(2),
        z_ids:identifier(2),
        Basename]),
    case is_unique_file(Filename, Context) of
        true ->
            Filename;
        false ->
            make_preview_unique(RscId, Extension, Context)
    end.

id_to_list(N) when is_integer(N) -> integer_to_list(N);
id_to_list(insert_rsc) -> "video".

is_unique_file(Filename, Context) ->
    z_db:q1("select count(*) from medium_log where filename = $1", [Filename], Context) =:= 0.

medium_insert(Id, Props, Context) ->
    IsA = m_rsc:is_a(Id, Context),
    Props1 = check_medium_props(Props),
    case z_db:insert(medium, Props1, Context) of
        {ok, _} = OK ->
            z_notifier:notify(#media_update_done{action=insert, id=Id, post_is_a=IsA, pre_is_a=[], pre_props=#{}, post_props=Props1}, Context),
            OK;
        {error, _} = Error ->
            Error
    end.

medium_delete(Id, Context) ->
    medium_delete(Id, get(Id, Context), Context).

medium_delete(_Id, undefined, _Context) ->
    {ok, 0};
medium_delete(Id, Props, Context) ->
    IsA = m_rsc:is_a(Id, Context),
    case z_db:delete(medium, Id, Context) of
        {ok, _} = OK ->
            z_notifier:notify(#media_update_done{action=delete, id=Id, pre_is_a=IsA, post_is_a=[], pre_props=Props, post_props=#{}}, Context),
            OK;
        {error, _} = Error ->
            Error
    end.

check_medium_props(Ps) ->
    maps:fold(
        fun(K, V, Acc) ->
            V1 = check_medium_prop(K, V),
            Acc#{
                K => V1
            }
        end,
        #{},
        Ps).

check_medium_prop(<<"width">>, N) when not is_integer(N) -> 0;
check_medium_prop(<<"height">>, N) when not is_integer(N) -> 0;
check_medium_prop(B, P) when is_binary(B) -> P.


% Return a map with basic (not too sensitive) medium info for MQTT events
-spec mqtt_event_info( map() ) -> map().
mqtt_event_info(Medium) ->
    lists:foldl(
        fun(K, Acc) ->
            Acc#{
                K => maps:get(K, Medium, undefined)
            }
        end,
        #{},
        [
            id, size, width, height,
            orientation, mime, filename
        ]).