• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

camatcode / basenji / 4e6ab01ab8578dff199cf92f5b41a9c641c12b53

03 Aug 2025 05:03PM UTC coverage: 84.192%. Remained the same
4e6ab01ab8578dff199cf92f5b41a9c641c12b53

Pull #85

github

camatcode
refactor: Reader behaviour
Pull Request #85: refactor: Reader behaviour

46 of 46 new or added lines in 6 files covered. (100.0%)

16 existing lines in 8 files now uncovered.

1209 of 1436 relevant lines covered (84.19%)

487.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.75
/lib/basenji/reader/reader.ex
1
defmodule Basenji.Reader do
2
  @moduledoc false
3

4
  alias Basenji.Reader.CB7Reader
5
  alias Basenji.Reader.CBRReader
6
  alias Basenji.Reader.CBTReader
7
  alias Basenji.Reader.CBZReader
8
  alias Basenji.Reader.PDFReader
9
  alias Basenji.Reader.Process.JPEGOptimizer
10
  alias Basenji.Reader.Process.PNGOptimizer
11
  alias Porcelain.Result
12

13
  @readers [
14
    CBZReader,
15
    CBRReader,
16
    CB7Reader,
17
    CBTReader,
18
    PDFReader
19
  ]
20

21
  @optimizers [
22
    JPEGOptimizer,
23
    PNGOptimizer
24
  ]
25

26
  @image_extensions [
27
    "jpeg",
28
    "jpg",
29
    "jpe",
30
    "jif",
31
    "jfi",
32
    "jfif",
33
    "heif",
34
    "heic",
35
    "png",
36
    "gif",
37
    "svg",
38
    "eps",
39
    "webp",
40
    "tiff",
41
    "tif"
42
  ]
43

44
  @callback read(file_path :: String.t(), opts :: list()) :: {:ok, map()} | {:error, any()}
45
  @callback format() :: atom()
46
  @callback file_extensions() :: list()
47
  @callback close(any()) :: :ok | :error
48
  @callback magic_numbers :: [map()]
49
  @callback get_entries(file_path :: String.t(), opts :: list()) :: {:ok, map()} | {:error, any()}
50

51
  def read(file_path, opts \\ []) do
52
    opts = Keyword.merge([optimize: true], opts)
4,010✔
53

54
    reader = find_reader(file_path)
4,010✔
55

56
    if reader do
4,010✔
57
      read_result = reader.read(file_path, opts)
4,010✔
58
      if opts[:optimize], do: optimize_entries(read_result), else: read_result
4,010✔
59
    else
60
      {:error, :no_reader_found}
61
    end
62
  end
63

64
  def stream_pages(file_path, opts \\ []) do
65
    opts = Keyword.merge([start_page: 1, optimize: true], opts)
68✔
66

67
    with {:ok, %{entries: entries}} <- read(file_path, opts) do
68✔
68
      stream =
68✔
69
        opts[:start_page]..Enum.count(entries)
70
        |> Stream.map(fn idx ->
71
          at = idx - 1
272✔
72
          Enum.at(entries, at).stream_fun.()
272✔
73
        end)
74

75
      {:ok, stream}
76
    end
77
  end
78

79
  def title_from_location(location) do
80
    location
81
    |> Path.basename()
82
    |> Path.rootname()
83
    |> ProperCase.snake_case()
84
    |> String.split("_")
85
    |> Enum.map_join(" ", &String.capitalize(&1))
47✔
86
  end
87

88
  def info(location, opts \\ []) do
89
    Cachex.fetch(
90
      :basenji_cache,
91
      info_cache_key(location, opts),
92
      fn _key ->
93
        any_result = get_info(location, opts)
49✔
94
        {:commit, any_result, [ttl: to_timeout(minute: 5)]}
49✔
95
      end
96
    )
97
    |> case do
200✔
UNCOV
98
      {:ignore, {:error, error}} ->
×
99
        {:error, error}
100

101
      {_, result} ->
102
        result
200✔
103

104
      response ->
UNCOV
105
        response
×
106
    end
107
  end
108

109
  def exec(cmd, args, opts \\ []) do
110
    Porcelain.exec(cmd, args, opts)
111
    |> case do
4,140✔
112
      %Result{out: output, status: 0} ->
4,138✔
113
        {:ok, output |> String.trim()}
114

115
      other ->
2✔
116
        {:error, other}
117
    end
118
  end
119

120
  def create_resource(make_func) do
121
    Stream.resource(
880✔
122
      make_func,
123
      fn
124
        :halt -> {:halt, nil}
880✔
125
        func -> {func, :halt}
880✔
126
      end,
127
      fn _ -> nil end
880✔
128
    )
129
  end
130

131
  def sort_file_names(e), do: Enum.sort_by(e, & &1.file_name)
3,284✔
132

133
  def reject_macos_preview(e), do: Enum.reject(e, &String.contains?(&1.file_name, "__MACOSX"))
3,284✔
134

135
  def reject_directories(e), do: Enum.reject(e, &(Path.extname(&1.file_name) == ""))
3,284✔
136

137
  def reject_non_image(e) do
138
    Enum.filter(e, fn ent ->
3,284✔
139
      ext = Path.extname(ent.file_name) |> String.replace(".", "") |> String.downcase()
13,136✔
140
      ext in @image_extensions
13,136✔
141
    end)
142
  end
143

144
  defp matches_extension?(reader, filepath) do
145
    file_ext = Path.extname(filepath) |> String.downcase()
12,006✔
146

147
    reader.file_extensions()
12,006✔
148
    |> Enum.reduce_while(false, fn ext, acc ->
12,006✔
149
      if String.ends_with?(file_ext, ext), do: {:halt, true}, else: {:cont, acc}
12,006✔
150
    end)
151
  end
152

153
  defp matches_magic?(reader, file_path) do
154
    reader.magic_numbers()
4,057✔
155
    |> Enum.reduce_while(
4,057✔
156
      nil,
157
      fn %{offset: offset, magic: magic}, _acc ->
158
        try do
4,057✔
159
          bytes =
4,057✔
160
            File.stream!(file_path, offset + Enum.count(magic), [])
161
            |> Enum.take(1)
162
            |> hd()
163
            |> :binary.bin_to_list()
164
            |> Enum.take(-1 * Enum.count(magic))
165

166
          if bytes == magic, do: {:halt, true}, else: {:cont, nil}
4,057✔
167
        rescue
UNCOV
168
          _e ->
×
169
            {:cont, nil}
170
        end
171
      end
172
    )
173
  end
174

175
  defp optimize_entries({:ok, result}) do
176
    updated_entries =
3,958✔
177
      Map.get(result, :entries)
178
      |> Enum.map(fn entry ->
179
        stream_fun = fn ->
15,832✔
180
          create_resource(fn -> [optimize(entry.stream_fun.()) |> :binary.bin_to_list()] end)
332✔
181
        end
182

183
        Map.put(entry, :stream_fun, stream_fun)
15,832✔
184
      end)
185

186
    result = Map.put(result, :entries, updated_entries)
3,958✔
187

188
    {:ok, result}
189
  end
190

UNCOV
191
  defp optimize_entries(other), do: other
×
192

193
  defp optimize(bytes) do
194
    @optimizers
195
    |> Enum.reduce(bytes |> Enum.to_list(), fn reader, bytes ->
332✔
196
      reader.optimize!(bytes)
664✔
197
    end)
198
  end
199

200
  defp info_cache_key(location, opts), do: %{location: location, opts: opts}
200✔
201

202
  defp get_info(location, opts) do
203
    opts = Keyword.merge([include_hash: false], opts)
49✔
204
    reader = find_reader(location)
49✔
205

206
    info =
49✔
207
      if reader do
2✔
208
        title = title_from_location(location)
47✔
209

210
        {:ok, response} = reader.read(location, opts)
47✔
211
        %{entries: entries} = response
47✔
212
        reader.close(response[:file])
47✔
213

214
        %{
47✔
215
          format: reader.format(),
47✔
216
          resource_location: location,
217
          title: title,
218
          page_count: Enum.count(entries)
219
        }
220
      else
221
        {:error, :unreadable}
222
      end
223

224
    info
225
    |> case do
49✔
226
      {:error, e} -> {:error, e}
2✔
227
      inf -> {:ok, inf}
47✔
228
    end
229
  end
230

231
  defp find_reader(file_path) do
232
    @readers
233
    |> Enum.reduce_while(
4,059✔
234
      nil,
235
      fn reader, _acc ->
236
        if matches_extension?(reader, file_path) && matches_magic?(reader, file_path),
12,006✔
237
          do: {:halt, reader},
238
          else: {:cont, nil}
239
      end
240
    )
241
  end
242
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc