diff --git a/lib/live_beats/application.ex b/lib/live_beats/application.ex index e6ff89f..ed99555 100644 --- a/lib/live_beats/application.ex +++ b/lib/live_beats/application.ex @@ -25,7 +25,8 @@ defmodule LiveBeats.Application do presence: LiveBeatsWeb.Presence, name: PresenceClient}, # Start the Endpoint (http/https) - LiveBeatsWeb.Endpoint + LiveBeatsWeb.Endpoint, + {LiveBeats.SongsCleaner, count: 7, interval: :day} # Start a worker by calling: LiveBeats.Worker.start_link(arg) # {LiveBeats.Worker, arg} diff --git a/lib/live_beats/media_library.ex b/lib/live_beats/media_library.ex index e316257..767d5c5 100644 --- a/lib/live_beats/media_library.ex +++ b/lib/live_beats/media_library.ex @@ -191,7 +191,8 @@ defmodule LiveBeats.MediaLibrary do consume_file.(ref, fn tmp_path -> store_mp3(song, tmp_path) end) {ref, song} end) - broadcast_imported(user, songs) + + broadcast_imported(user, songs) {:ok, Enum.into(songs, %{})} @@ -336,6 +337,74 @@ defmodule LiveBeats.MediaLibrary do end def delete_song(%Song{} = song) do + delete_song_file(song) + + Ecto.Multi.new() + |> Ecto.Multi.delete(:delete, song) + |> update_user_songs_count(song.user_id, -1) + |> Repo.transaction() + |> case do + {:ok, _} -> :ok + other -> other + end + end + + def expire_songs_older_than(count, interval) when interval in [:month, :day, :second] do + Ecto.Multi.new() + |> Ecto.Multi.delete_all( + :delete_expired_songs, + from(s in Song, + where: s.inserted_at < from_now(^(-count), ^to_string(interval)), + select: %{user_id: s.user_id, mp3_filepath: s.mp3_filepath} + ) + ) + |> Ecto.Multi.merge(&update_users_songs_count(&1)) + |> Repo.transaction() + |> case do + {:ok, transaction_result} -> + {_deleted_songs_count, deleted_songs} = transaction_result.delete_expired_songs + Enum.each(deleted_songs, &delete_song_file/1) + + error -> + error + end + end + + defp update_users_songs_count(%{delete_expired_songs: results}) do + {_deleted_songs_count, deleted_songs} = results + + deleted_songs + |> Enum.reduce(%{}, &acc_user_songs/2) + |> Enum.reduce(Ecto.Multi.new(), &decrement_user_songs_count/2) + end + + defp decrement_user_songs_count({user_id, deleted_songs_count}, multi) do + update_user_songs_count(multi, user_id, deleted_songs_count * -1) + end + + defp update_user_songs_count(multi, user_id, songs_count) do + Ecto.Multi.update_all( + multi, + "update_songs_count_user_#{user_id}", + fn _ -> + from(u in Accounts.User, + where: u.id == ^user_id, + update: [inc: [songs_count: ^songs_count]] + ) + end, + [] + ) + end + + defp acc_user_songs(%{user_id: user_id} = _song, songs_acc) do + if Map.has_key?(songs_acc, user_id) do + Map.put(songs_acc, user_id, songs_acc[user_id] + 1) + else + Map.put_new(songs_acc, user_id, 1) + end + end + + defp delete_song_file(song) do case File.rm(song.mp3_filepath) do :ok -> :ok @@ -345,24 +414,6 @@ defmodule LiveBeats.MediaLibrary do "unable to delete song #{song.id} at #{song.mp3_filepath}, got: #{inspect(reason)}" ) end - - Ecto.Multi.new() - |> Ecto.Multi.delete(:delete, song) - |> Ecto.Multi.update_all( - :update_songs_count, - fn _ -> - from(u in Accounts.User, - where: u.id == ^song.user_id, - update: [inc: [songs_count: -1]] - ) - end, - [] - ) - |> Repo.transaction() - |> case do - {:ok, _} -> :ok - other -> other - end end def change_song(song_or_changeset, attrs \\ %{}) diff --git a/lib/live_beats/songs_cleaner.ex b/lib/live_beats/songs_cleaner.ex new file mode 100644 index 0000000..5d3ec1b --- /dev/null +++ b/lib/live_beats/songs_cleaner.ex @@ -0,0 +1,34 @@ +defmodule LiveBeats.SongsCleaner do + @moduledoc """ + Expire user songs using a polling interval. + """ + use GenServer + + alias LiveBeats.MediaLibrary + + @poll_interval :timer.minutes(60) + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts) + end + + @impl true + def init(opts) do + count = Keyword.fetch!(opts, :count) + interval = Keyword.fetch!(opts, :interval) + MediaLibrary.expire_songs_older_than(count, interval) + + {:ok, schedule_cleanup(%{count: count, interval: interval})} + end + + @impl true + def handle_info(:remove_songs, %{count: count, interval: interval} = state) do + MediaLibrary.expire_songs_older_than(count, interval) + {:noreply, schedule_cleanup(state)} + end + + defp schedule_cleanup(state) do + Process.send_after(self(), :remove_songs, @poll_interval) + state + end +end diff --git a/test/live_beats/media_library_test.exs b/test/live_beats/media_library_test.exs index 6c4beaf..929bbb9 100644 --- a/test/live_beats/media_library_test.exs +++ b/test/live_beats/media_library_test.exs @@ -2,14 +2,20 @@ defmodule LiveBeats.MediaLibraryTest do use LiveBeats.DataCase alias LiveBeats.MediaLibrary + alias LiveBeats.Accounts + alias LiveBeats.MediaLibrary.Song + import LiveBeats.AccountsFixtures + import LiveBeats.MediaLibraryFixtures describe "songs" do - alias LiveBeats.MediaLibrary.Song - - import LiveBeats.AccountsFixtures - import LiveBeats.MediaLibraryFixtures - - @invalid_attrs %{album_artist: nil, artist: nil, date_recorded: nil, date_released: nil, duration: nil, title: nil} + @invalid_attrs %{ + album_artist: nil, + artist: nil, + date_recorded: nil, + date_released: nil, + duration: nil, + title: nil + } test "list_profile_songs/1 returns all songs for a profile" do user = user_fixture() @@ -25,7 +31,15 @@ defmodule LiveBeats.MediaLibraryTest do test "update_song/2 with valid data updates the song" do song = song_fixture() - update_attrs = %{album_artist: "some updated album_artist", artist: "some updated artist", date_recorded: ~N[2021-10-27 20:11:00], date_released: ~N[2021-10-27 20:11:00], duration: 43, title: "some updated title"} + + update_attrs = %{ + album_artist: "some updated album_artist", + artist: "some updated artist", + date_recorded: ~N[2021-10-27 20:11:00], + date_released: ~N[2021-10-27 20:11:00], + duration: 43, + title: "some updated title" + } assert {:ok, %Song{} = song} = MediaLibrary.update_song(song, update_attrs) assert song.album_artist == "some updated album_artist" @@ -42,11 +56,17 @@ defmodule LiveBeats.MediaLibraryTest do assert song == MediaLibrary.get_song!(song.id) end - test "delete_song/1 deletes the song" do + test "delete_song/1 deletes the song and decrement the user's songs_count" do user = user_fixture() + + user + |> Ecto.Changeset.change(songs_count: 10) + |> LiveBeats.Repo.update() + song = song_fixture(%{user_id: user.id}) assert :ok = MediaLibrary.delete_song(song) assert_raise Ecto.NoResultsError, fn -> MediaLibrary.get_song!(song.id) end + assert Accounts.get_user(user.id).songs_count == 9 end test "change_song/1 returns a song changeset" do @@ -54,4 +74,75 @@ defmodule LiveBeats.MediaLibraryTest do assert %Ecto.Changeset{} = MediaLibrary.change_song(song) end end + + describe "expire_songs_older_than/2" do + setup do + today = DateTime.utc_now() + + creation_dates = Enum.map([-1, -3, -4], &add_n_months(today, &1)) + + %{creation_dates: creation_dates} + end + + test "deletes the songs expired before the required interval", %{ + creation_dates: [one_month_ago, three_months_ago, four_months_ago] + } do + user = user_fixture() + + expired_song_1 = + song_fixture(user_id: user.id, title: "song1", inserted_at: four_months_ago) + + expired_song_2 = + song_fixture(user_id: user.id, title: "song2", inserted_at: three_months_ago) + + active_song = song_fixture(user_id: user.id, title: "song3", inserted_at: one_month_ago) + + MediaLibrary.expire_songs_older_than(2, :month) + + assert_raise Ecto.NoResultsError, fn -> MediaLibrary.get_song!(expired_song_1.id) end + assert_raise Ecto.NoResultsError, fn -> MediaLibrary.get_song!(expired_song_2.id) end + assert active_song == MediaLibrary.get_song!(active_song.id) + end + + test "Users song_count is decremented when user songs are deleted", %{ + creation_dates: creation_dates + } do + user = user_fixture() + + songs_changesets = + ["1", "2", "3"] + |> Enum.reduce(%{}, fn song_number, acc -> + song_changeset = + Song.changeset(%Song{}, %{title: "song#{song_number}", artist: "artist_one"}) + + Map.put_new(acc, song_number, song_changeset) + end) + + assert {:ok, results} = + MediaLibrary.import_songs(user, songs_changesets, fn one, two -> {one, two} end) + + assert Accounts.get_user(user.id).songs_count == 3 + + created_songs = Enum.reduce(results, [], fn {_key, song}, acc -> [song | acc] end) + + for {song, date} <- Enum.zip(created_songs, creation_dates) do + song + |> Ecto.Changeset.change(inserted_at: date) + |> LiveBeats.Repo.update() + end + + MediaLibrary.expire_songs_older_than(2, :month) + + assert Accounts.get_user(user.id).songs_count == 1 + end + + defp add_n_months(datetime, n) do + seconds = 30 * (60 * 60 * 24) * n + + datetime + |> DateTime.add(seconds, :second) + |> DateTime.to_naive() + |> NaiveDateTime.truncate(:second) + end + end end