diff --git a/config/config.exs b/config/config.exs index 8ea98577..5b8ba69d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -58,11 +58,6 @@ config :music_library, LastFm, user_agent: user_agent, api_cooldown: 500 -config :music_library, MusicLibrary.ListeningStats.Refresh, - auto_refresh: true, - # refresh every 5 minutes - refresh_interval: System.convert_time_unit(300, :second, :millisecond) - config :music_library, MusicBrainz, user_agent: user_agent, api_cooldown: 500 config :music_library, Discogs, diff --git a/config/dev.exs b/config/dev.exs index 1a6ca95a..2a702505 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -81,8 +81,15 @@ config :music_library, monitoring_routes: true config :music_library, MusicLibrary.Mailer, adapter: Swoosh.Adapters.Local -config :music_library, MusicLibrary.ListeningStats.Refresh, - refresh_interval: System.convert_time_unit(500, :second, :millisecond) +config :music_library, Oban, + plugins: [ + {Oban.Plugins.Cron, + timezone: "Europe/London", + crontab: [ + # every 8 minutes (closest cron equivalent to previous 500s dev interval) + {"*/8 * * * *", MusicLibrary.Worker.RefreshScrobbles} + ]} + ] # Do not include metadata nor timestamps in development logs config :logger, :default_formatter, format: "[$level] $message\n" diff --git a/config/prod.exs b/config/prod.exs index 97e04c58..3718c3fd 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -46,7 +46,9 @@ config :music_library, Oban, {"0 9 1 * *", MusicLibrary.Worker.ArtistRefreshAllDiscogsData}, {"0 10 1 * *", MusicLibrary.Worker.ArtistRefreshAllWikipediaData}, # daily at 7 am - {"0 7 * * *", MusicLibrary.Worker.SendRecordsOnThisDayEmail} + {"0 7 * * *", MusicLibrary.Worker.SendRecordsOnThisDayEmail}, + # every 5 minutes + {"*/5 * * * *", MusicLibrary.Worker.RefreshScrobbles} ]} ] diff --git a/config/test.exs b/config/test.exs index 5ff7d4b8..616bca6c 100644 --- a/config/test.exs +++ b/config/test.exs @@ -62,8 +62,6 @@ config :music_library, LastFm, ], api_cooldown: 0 -config :music_library, MusicLibrary.ListeningStats.Refresh, auto_refresh: false - config :music_library, MusicBrainz, req_options: [ plug: {Req.Test, MusicBrainz.API}, diff --git a/docs/architecture.md b/docs/architecture.md index d756aa07..7b193651 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,6 @@ MusicLibrary.Application (one_for_one) ├── Ecto.Migrator # Auto-migration on boot ├── Task.Supervisor (MusicLibrary.TaskSupervisor) ├── Phoenix.PubSub (:music_library) -├── MusicLibrary.ListeningStats.Refresh # GenServer, periodic scrobble fetch └── MusicLibraryWeb.Endpoint ``` @@ -198,6 +197,7 @@ stubbed via `Req.Test` (configured in `config/test.exs`). | `ArtistRefreshAllMusicBrainzData` | music_brainz | Manual / cron (bulk refresh via Artists.Batch) | | `ArtistRefreshAllDiscogsData` | discogs | Manual / cron (bulk refresh via Artists.Batch) | | `ArtistRefreshAllWikipediaData` | wikipedia | Manual / cron (bulk refresh via Artists.Batch) | +| `RefreshScrobbles` | last_fm | Cron / manual (fetch recent Last.fm scrobbles) | | `BackfillScrobbledTracks` | heavy_writes | Manual (self-chaining batch import) | | `SendRecordsOnThisDayEmail` | default | Cron (daily "records on this day" email) | @@ -216,6 +216,7 @@ stubbed via `Req.Test` (configured in `config/test.exs`). | Monthly 1st, 9 AM | `ArtistRefreshAllDiscogsData` | discogs | | Monthly 1st, 10 AM | `ArtistRefreshAllWikipediaData` | wikipedia | | Daily 7 AM | `SendRecordsOnThisDayEmail` | default | +| Every 5 min | `RefreshScrobbles` | last_fm | --- diff --git a/lib/music_library/application.ex b/lib/music_library/application.ex index 823af511..b031ef49 100644 --- a/lib/music_library/application.ex +++ b/lib/music_library/application.ex @@ -24,9 +24,6 @@ defmodule MusicLibrary.Application do repos: Application.fetch_env!(:music_library, :ecto_repos), skip: skip_migrations?()}, {Task.Supervisor, name: MusicLibrary.TaskSupervisor}, {Phoenix.PubSub, name: MusicLibrary.PubSub}, - {MusicLibrary.ListeningStats.Refresh, - {LastFm.Config.resolve(:music_library), - Application.fetch_env!(:music_library, MusicLibrary.ListeningStats.Refresh)}}, # Start a worker by calling: MusicLibrary.Worker.start_link(arg) # {MusicLibrary.Worker, arg}, # Start to serve requests, typically the last entry diff --git a/lib/music_library/listening_stats.ex b/lib/music_library/listening_stats.ex index c730e186..10678f72 100644 --- a/lib/music_library/listening_stats.ex +++ b/lib/music_library/listening_stats.ex @@ -15,7 +15,6 @@ defmodule MusicLibrary.ListeningStats do Artists, BackgroundRepo, Collection, - ListeningStats.Refresh, Records.ArtistRecord, Records.Record, Repo, @@ -76,9 +75,10 @@ defmodule MusicLibrary.ListeningStats do Phoenix.PubSub.subscribe(MusicLibrary.PubSub, "listening_stats:update") end - @spec refresh() :: :ok | {:error, term()} + @spec refresh() :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()} def refresh do - Refresh.refresh() + Worker.RefreshScrobbles.new(%{}) + |> Oban.insert() end @spec lowest_scrobbled_at_uts() :: integer() | nil diff --git a/lib/music_library/listening_stats/refresh.ex b/lib/music_library/listening_stats/refresh.ex deleted file mode 100644 index c3d4b86c..00000000 --- a/lib/music_library/listening_stats/refresh.ex +++ /dev/null @@ -1,101 +0,0 @@ -defmodule MusicLibrary.ListeningStats.Refresh do - @moduledoc """ - A GenServer that manages periodic refreshing of Last.fm scrobbled tracks. - - Fetches recent tracks from Last.fm at configurable intervals and persists - them via `MusicLibrary.ListeningStats.update/1`. - - ## Configuration - - Accepts a tuple of `{LastFm.Config.t(), keyword()}` where the keyword list contains: - - `auto_refresh` — when true, automatically starts refreshing on init - - `refresh_interval` — time in milliseconds between refresh attempts - - ## Operation Modes - - 1. Automatic Mode (`auto_refresh: true`): - - Starts refreshing immediately on initialization - - Continues to refresh at the configured interval - - Handles failures gracefully by continuing to retry - - 2. Manual Mode (`auto_refresh: false`): - - Server remains dormant on initialization - - Refreshes only occur via explicit `refresh/0` calls - """ - - use GenServer - - alias LastFm.{API, Config} - alias MusicLibrary.ListeningStats - - @type state :: %{ - api_config: Config.t(), - auto_refresh: boolean(), - refresh_interval: pos_integer() - } - - @spec start_link({Config.t(), keyword()}) :: GenServer.on_start() - def start_link({api_config, refresh_opts}) do - state = %{ - api_config: api_config, - auto_refresh: Keyword.get(refresh_opts, :auto_refresh, true), - refresh_interval: Keyword.get(refresh_opts, :refresh_interval, 60_000) - } - - GenServer.start_link(__MODULE__, state, name: __MODULE__) - end - - @spec refresh() :: :ok - def refresh do - GenServer.call(__MODULE__, :refresh, 10_000) - end - - @impl true - @spec init(state) :: {:ok, state, {:continue, :refresh}} | :ignore - def init(state) do - if state.auto_refresh do - {:ok, state, {:continue, :refresh}} - else - :ignore - end - end - - @impl true - @spec handle_call(:refresh, GenServer.from(), state) :: - {:reply, :ok | {:error, term()}, state, pos_integer()} - def handle_call(:refresh, _from, state) do - case API.get_recent_tracks(state.api_config) do - {:ok, tracks} -> - ListeningStats.update(tracks) - {:reply, :ok, state, state.refresh_interval} - - error -> - {:reply, error, state, state.refresh_interval} - end - end - - @impl true - @spec handle_continue(atom(), state) :: {:noreply, state, pos_integer()} - def handle_continue(:refresh, state), do: do_refresh(state) - - @impl true - @spec handle_info(atom(), state) :: {:noreply, state, pos_integer()} - def handle_info(:refresh, state), do: do_refresh(state) - - def handle_info(:timeout, state), do: do_refresh(state) - - defp do_refresh(state) do - case API.get_recent_tracks(state.api_config) do - {:ok, tracks} -> - ListeningStats.update(tracks) - {:noreply, state, state.refresh_interval} - - {:error, error} -> - if API.ErrorResponse.retryable_error?(error) do - {:noreply, state, API.ErrorResponse.retry_delay(error)} - else - {:stop, error, state} - end - end - end -end diff --git a/lib/music_library/worker/refresh_scrobbles.ex b/lib/music_library/worker/refresh_scrobbles.ex new file mode 100644 index 00000000..d56c1566 --- /dev/null +++ b/lib/music_library/worker/refresh_scrobbles.ex @@ -0,0 +1,35 @@ +defmodule MusicLibrary.Worker.RefreshScrobbles do + @moduledoc """ + Fetches recent scrobbled tracks from Last.fm and persists them. + + Scheduled via Oban Cron and can be triggered manually via + `MusicLibrary.ListeningStats.refresh/0`. + """ + + use Oban.Worker, + queue: :last_fm, + max_attempts: 3, + unique: [period: 60, states: [:available, :scheduled, :executing]] + + alias LastFm.{API, Config} + alias MusicLibrary.ListeningStats + + @impl Oban.Worker + def perform(_job) do + api_config = Config.resolve(:music_library) + + case API.get_recent_tracks(api_config) do + {:ok, tracks} -> + ListeningStats.update(tracks) + :ok + + {:error, %API.ErrorResponse{error: error} = resp} -> + if API.ErrorResponse.retryable_error?(error) do + seconds = error |> API.ErrorResponse.retry_delay() |> div(1000) + {:snooze, seconds} + else + {:cancel, inspect(resp)} + end + end + end +end