%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2012-2013 Marc Worrell
%% @doc Manage, compile and find mediaclass definitions per context/site.
%% Copyright 2012-2013 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(z_mediaclass).
-author("Marc Worrell <marc@worrell.nl>").
-behaviour(gen_server).
%% gen_server exports
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-export([start_link/1]).
%% API exports
-export([
new_ets/0,
get/2,
expand_mediaclass_checksum/1,
expand_mediaclass_checksum/2,
expand_mediaclass/2,
reset/1,
module_reindexed/2
]).
-record(state, {
context :: z:context(),
last = []
}).
%% Index record for the mediaclass ets table.
-record(mediaclass_index_key, {
site :: atom(),
mediaclass
}).
-record(mediaclass_index, {
key,
props = [],
checksum,
tag
}).
-define(MEDIACLASS_FILENAME, <<"mediaclass.config">>).
-define(RESCAN_PERIOD, 10000).
%% Name of the global mediaclass index table
-define(MEDIACLASS_INDEX, 'zotonic$mediaclass_index').
-include("zotonic.hrl").
%%====================================================================
%% API
%%====================================================================
-spec new_ets() -> ets:tid() | atom().
new_ets() ->
ets:new(?MEDIACLASS_INDEX, [set, public, named_table, {keypos, #mediaclass_index.key}]).
%% @spec start_link(Props) -> {ok,Pid} | ignore | {error,Error}
%% @doc Starts the server
start_link(Site) ->
Name = z_utils:name_for_site(?MODULE, Site),
gen_server:start_link({local, Name}, ?MODULE, Site, []).
%% @doc Fetch the mediaclass definition for the current context.
-spec get(MediaClass :: list() | binary(), #context{}) -> {ok, PreviewProps :: list(), Checksum :: binary()}.
get(MediaClass, Context) ->
case ets:lookup(?MEDIACLASS_INDEX,
#mediaclass_index_key{
site=z_context:site(Context),
mediaclass=z_convert:to_binary(MediaClass)})
of
[] -> {ok, [], <<>>};
[MC|_] -> {ok, MC#mediaclass_index.props, MC#mediaclass_index.checksum}
end.
%% @doc Expand the mediaclass, use the checksum when available
-spec expand_mediaclass_checksum(list() | binary()) -> {ok, list()} | {error, term()}.
expand_mediaclass_checksum(Checksum) when is_binary(Checksum) ->
expand_mediaclass_checksum(Checksum, []);
expand_mediaclass_checksum(Props) ->
case proplists:get_value(mediaclass, Props) of
undefined ->
{ok, Props};
{MC, Checksum} when Checksum =/= undefined ->
expand_mediaclass_checksum_1(MC, Checksum, Props);
Checksum ->
expand_mediaclass_checksum_1(undefined, z_convert:to_binary(Checksum), Props)
end.
-spec expand_mediaclass_checksum(Checksum :: binary(), Props :: list()) -> {ok, list()} | {error, checksum}.
expand_mediaclass_checksum(Checksum, Props) ->
expand_mediaclass_checksum_1(undefined, Checksum, Props).
expand_mediaclass_checksum_1(Class, Checksum, Props) ->
% Expand for preview generation, we got the checksum from the URL.
case ets:lookup(?MEDIACLASS_INDEX, Checksum) of
[#mediaclass_index{props=Ps}|_] ->
{ok, expand_mediaclass_2(Props, Ps)};
[] ->
?LOG_WARNING(#{
text => <<"mediaclass expand for unknown mediaclass checksum">>,
in => zotonic_core,
result => error,
reason => checksum,
mediaclass => Class,
checksum => Checksum
}),
{error, checksum}
end.
%% @doc Expand the optional mediaclass for tag generation
-spec expand_mediaclass(list(), #context{}) -> {ok, list()}.
expand_mediaclass(Props, Context) ->
case proplists:get_value(mediaclass, Props) of
MC when is_list(MC); is_binary(MC) ->
% Expand for tag generation
expand_mediaclass_1(MC, Props, Context);
{MC, _Checksum} when is_list(MC); is_binary(MC) ->
expand_mediaclass_1(MC, Props, Context);
undefined ->
{ok, Props}
end.
expand_mediaclass_1(MC, Props, Context) ->
{ok, Ps, _Checksum} = get(MC, Context),
{ok, expand_mediaclass_2(Props, Ps)}.
expand_mediaclass_2(Props, ClassProps) ->
lists:foldl(fun(KV, Acc) ->
K = key(KV),
case proplists:is_defined(K, Acc) of
false -> [ KV | Acc ];
true -> Acc
end
end,
proplists:delete(mediaclass, Props),
ClassProps).
key({K,_}) -> K;
key(K) -> K.
%% @doc Call this to force a re-index and parse of all moduleclass definitions.
-spec reset(#context{}) -> ok.
reset(Context) ->
gen_server:cast(z_utils:name_for_site(?MODULE, Context), module_reindexed).
%% @doc Observer, triggered when there are new module files indexed
-spec module_reindexed(module_reindexed, #context{}) -> ok.
module_reindexed(module_reindexed, Context) ->
reset(Context).
%%====================================================================
%% gen_server callbacks
%%====================================================================
%% @spec init(SiteProps) -> {ok, State} |
%% {ok, State, Timeout} |
%% ignore |
%% {stop, Reason}
%% @doc Initiates the server.
init(Site) ->
process_flag(trap_exit, true),
logger:set_process_metadata(#{
site => Site,
module => ?MODULE
}),
Context = z_context:new(Site),
z_notifier:observe(module_reindexed, {?MODULE, module_reindexed}, Context),
{ok, #state{context=Context}}.
%% @spec handle_call(Request, From, State) -> {reply, Reply, State} |
%% {reply, Reply, State, Timeout} |
%% {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, Reply, State} |
%% {stop, Reason, State}
handle_call(Message, _From, State) ->
{stop, {unknown_call, Message}, State, ?RESCAN_PERIOD}.
%% @spec handle_cast(Msg, State) -> {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, State}
handle_cast(module_reindexed, State) ->
{noreply, reindex(State), ?RESCAN_PERIOD};
%% @doc Trap unknown casts
handle_cast(Message, State) ->
{stop, {unknown_cast, Message}, State, ?RESCAN_PERIOD}.
%% @spec handle_info(Info, State) -> {noreply, State} |
%% {noreply, State, Timeout} |
%% {stop, Reason, State}
%% @doc Check every RESCAN_PERIOD msecs if there are changes.
handle_info(timeout, State) ->
{noreply, reindex(State), ?RESCAN_PERIOD};
%% @doc Handling all non call/cast messages
handle_info(_Info, State) ->
{noreply, State, ?RESCAN_PERIOD}.
%% @spec terminate(Reason, State) -> void()
%% @doc This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any necessary
%% cleaning up. When it returns, the gen_server terminates with Reason.
%% The return value is ignored.
terminate(_Reason, State) ->
z_notifier:detach(module_reindexed, State#state.context),
ok.
%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
%% @doc Convert process state when code is changed
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%====================================================================
%% support functions
%%====================================================================
%% @doc Find all mediaclass files for all device types.
reindex(#state{context=Context, last=Last} = State) ->
case collect_files(Context) of
{ok, Last} ->
State;
{ok, {Fs,_MaxMod} = New} ->
% Something changed, parse and update all classes in the files.
Site = z_context:site(State#state.context),
ok = reindex_files(Fs, Site),
?LOG_DEBUG("Re-indexed mediaclass definitions"),
State#state{last=New}
end.
collect_files(Context) ->
case z_module_indexer:find_all(template, ?MEDIACLASS_FILENAME, Context) of
[] ->
{ok, {[], undefined}};
Ms when is_list(Ms) ->
Paths = [ {Module, Path} || #module_index{filepath=Path, module=Module} <- Ms ],
{ok, {Paths, lists:max([ filelib:last_modified(Path) || {_, Path} <- Paths ])}}
end.
reindex_files(Files, Site) ->
Tag = make_ref(),
MCs = lists:flatten([ expand_file(MP) || MP <- Files ]),
ByModulePrio = prio_sort(MCs),
% Insert least prio first, later overwrite with higher priority modules
[ insert_mcs(MCs1, Tag, Site) || MCs1 <- ByModulePrio ],
cleanup_ets(Tag, Site),
ok.
% Sort all defs, lowest prio first, higher prios later
prio_sort(MProps) ->
WithPrio = [ {z_module_manager:prio(M), M, X} || {M, X} <- MProps ],
Sorted = lists:sort(fun prio_comp/2, WithPrio),
[ X || {_Prio, _M, X} <- Sorted ].
prio_comp({P1, M1, _}, {P2, M2, _}) ->
{P1, M1} > {P2, M2}.
insert_mcs(MCs, Tag, Site) ->
Defs = [
begin
Props1 = lists:sort(Props),
{z_convert:to_binary(MediaClass),
Props1,
z_convert:to_binary(
z_string:to_lower(iolist_to_binary(z_utils:hex_encode(crypto:hash(sha, term_to_binary(Props1)))))
)}
end
|| {MediaClass, Props} <- lists:flatten(MCs)
],
insert_defs(Defs, Tag, Site).
insert_defs(Defs, Tag, Site) ->
[ insert_def(Def, Tag, Site) || Def <- Defs ].
% Insert the mediaclass definition by lookup key and checksum
insert_def({MC, Ps, Checksum}, Tag, Site) ->
K = #mediaclass_index_key{site=Site, mediaclass=MC},
ets:insert(?MEDIACLASS_INDEX,
#mediaclass_index{
key=K,
props=Ps,
checksum=Checksum,
tag=Tag
}),
ets:insert(?MEDIACLASS_INDEX,
#mediaclass_index{
key=Checksum,
props=Ps,
checksum=Checksum,
tag=Tag
}).
expand_file({Module, Path}) ->
{Module, lists:flatten(consult_file(Path))}.
consult_file(Path) ->
case file:consult(Path) of
{error, Reason} ->
% log an error and continue
?LOG_ERROR(#{
text => <<"Error consulting media class file">>,
in => zotonic_core,
file => Path,
result => error,
reason => Reason
}),
[];
{ok, MediaClasses} ->
MediaClasses
end.
%% @doc Remove all ets entries for this host with an old tag
cleanup_ets(Tag, Site) ->
ets:safe_fixtable(?MEDIACLASS_INDEX, true),
try
cleanup_ets_1(ets:first(?MEDIACLASS_INDEX), Tag, Site, [])
after
ets:safe_fixtable(?MEDIACLASS_INDEX, false)
end.
cleanup_ets_1('$end_of_table', _Tag, _Site, Acc) ->
[ ets:delete(?MEDIACLASS_INDEX, K) || K <- Acc ];
cleanup_ets_1(#mediaclass_index_key{site=Site} = K, Tag, Site, Acc) ->
case ets:lookup(?MEDIACLASS_INDEX, K) of
[#mediaclass_index{tag=Tag}] ->
cleanup_ets_1(ets:next(?MEDIACLASS_INDEX, K), Tag, Site, Acc);
_ ->
cleanup_ets_1(ets:next(?MEDIACLASS_INDEX, K), Tag, Site, [K|Acc])
end;
cleanup_ets_1(K, Tag, Site, Acc) ->
cleanup_ets_1(ets:next(?MEDIACLASS_INDEX, K), Tag, Site, Acc).