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

camatcode / basenji / 510183623a1afcec86a81c40dc2c479a483e7ff7

03 Aug 2025 05:27PM UTC coverage: 84.181% (-0.01%) from 84.192%
510183623a1afcec86a81c40dc2c479a483e7ff7

Pull #85

github

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

59 of 62 new or added lines in 6 files covered. (95.16%)

13 existing lines in 6 files now uncovered.

1208 of 1435 relevant lines covered (84.18%)

491.41 hits per line

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

94.2
/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 get_entry_stream!(location :: String.t(), file_name :: String.t()) :: Enumerable.t()
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,030✔
53

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

56
    if reader do
4,030✔
57
      read_result = reader.read(file_path, opts)
4,030✔
58
      if opts[:optimize], do: optimize_entries(read_result), else: read_result
4,030✔
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
196✔
UNCOV
98
      {:ignore, {:error, error}} ->
×
99
        {:error, error}
100

101
      {_, result} ->
102
        result
196✔
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,133✔
112
      %Result{out: output, status: 0} ->
4,131✔
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(
865✔
122
      make_func,
123
      fn
124
        :halt -> {:halt, nil}
865✔
125
        func -> {func, :halt}
865✔
126
      end,
127
      fn _ -> nil end
865✔
128
    )
129
  end
130

131
  def create_resource(cmd, args) do
132
    create_resource(fn ->
400✔
133
      with {:ok, output} <- exec(cmd, args) do
400✔
134
        [output |> :binary.bin_to_list()]
135
      end
136
    end)
137
  end
138

139
  def sort_and_reject(e) do
140
    e
141
    |> sort_file_names()
142
    |> reject_macos_preview()
143
    |> reject_directories()
144
    |> reject_non_image()
3,310✔
145
  end
146

147
  defp sort_file_names(e), do: Enum.sort_by(e, & &1.file_name)
3,310✔
148

149
  defp reject_macos_preview(e), do: Enum.reject(e, &String.contains?(&1.file_name, "__MACOSX"))
3,310✔
150

151
  defp reject_directories(e), do: Enum.reject(e, &(Path.extname(&1.file_name) == ""))
3,310✔
152

153
  defp reject_non_image(e) do
154
    Enum.filter(e, fn ent ->
3,310✔
155
      ext = Path.extname(ent.file_name) |> String.replace(".", "") |> String.downcase()
13,240✔
156
      ext in @image_extensions
13,240✔
157
    end)
158
  end
159

160
  defp matches_extension?(reader, filepath) do
161
    file_ext = Path.extname(filepath) |> String.downcase()
12,029✔
162

163
    reader.file_extensions()
12,029✔
164
    |> Enum.reduce_while(false, fn ext, acc ->
12,029✔
165
      if String.ends_with?(file_ext, ext), do: {:halt, true}, else: {:cont, acc}
12,029✔
166
    end)
167
  end
168

169
  defp matches_magic?(reader, file_path) do
170
    reader.magic_numbers()
4,077✔
171
    |> Enum.reduce_while(
4,077✔
172
      nil,
173
      fn %{offset: offset, magic: magic}, _acc ->
174
        try do
4,077✔
175
          bytes =
4,077✔
176
            File.stream!(file_path, offset + Enum.count(magic), [])
177
            |> Enum.take(1)
178
            |> hd()
179
            |> :binary.bin_to_list()
180
            |> Enum.take(-1 * Enum.count(magic))
181

182
          if bytes == magic, do: {:halt, true}, else: {:cont, nil}
4,077✔
183
        rescue
UNCOV
184
          _e ->
×
185
            {:cont, nil}
186
        end
187
      end
188
    )
189
  end
190

191
  defp optimize_entries({:ok, result}) do
192
    updated_entries =
3,979✔
193
      Map.get(result, :entries)
194
      |> Enum.map(fn entry ->
195
        stream_fun = fn ->
15,916✔
196
          create_resource(fn -> [optimize(entry.stream_fun.()) |> :binary.bin_to_list()] end)
325✔
197
        end
198

199
        Map.put(entry, :stream_fun, stream_fun)
15,916✔
200
      end)
201

202
    result = Map.put(result, :entries, updated_entries)
3,979✔
203

204
    {:ok, result}
205
  end
206

UNCOV
207
  defp optimize_entries(other), do: other
×
208

209
  defp optimize(bytes) do
210
    @optimizers
211
    |> Enum.reduce(bytes |> Enum.to_list(), fn reader, bytes ->
325✔
212
      reader.optimize!(bytes)
650✔
213
    end)
214
  end
215

216
  defp info_cache_key(location, opts), do: %{location: location, opts: opts}
196✔
217

218
  defp read_from_location(reader, location) do
219
    with {:ok, %{entries: file_entries}} <- reader.get_entries(location) do
47✔
220
      file_entries =
47✔
221
        file_entries
222
        |> Enum.map(&Map.put(&1, :stream_fun, fn -> reader.get_entry_stream!(location, &1) end))
188✔
223

224
      {:ok, %{entries: file_entries}}
225
    end
226
  end
227

228
  defp get_info(location, _opts) do
229
    reader = find_reader(location)
49✔
230

231
    info =
49✔
232
      if reader do
2✔
233
        title = title_from_location(location)
47✔
234

235
        {:ok, response} = read_from_location(reader, location)
47✔
236
        %{entries: entries} = response
47✔
237
        reader.close(response[:file])
47✔
238

239
        %{
47✔
240
          format: reader.format(),
47✔
241
          resource_location: location,
242
          title: title,
243
          page_count: Enum.count(entries)
244
        }
245
      else
246
        {:error, :unreadable}
247
      end
248

249
    info
250
    |> case do
49✔
251
      {:error, e} -> {:error, e}
2✔
252
      inf -> {:ok, inf}
47✔
253
    end
254
  end
255

256
  defp find_reader(file_path) do
257
    @readers
258
    |> Enum.reduce_while(
4,079✔
259
      nil,
260
      fn reader, _acc ->
261
        if matches_extension?(reader, file_path) && matches_magic?(reader, file_path),
12,029✔
262
          do: {:halt, reader},
263
          else: {:cont, nil}
264
      end
265
    )
266
  end
267
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