Skip to content

Commit

Permalink
Allow only overwriting year field without month and day. Closes #10
Browse files Browse the repository at this point in the history
  • Loading branch information
kernitus committed Oct 29, 2023
1 parent 17b798f commit bbc6201
Show file tree
Hide file tree
Showing 4 changed files with 65 additions and 26 deletions.
75 changes: 56 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
# beets-oldestdate
Beets plugin that fetches oldest recording or release date for each track. This is especially useful when tracks are from best-of compilations, remasters, or re-releases. Originally based on `beets-recordingdate` by tweitzel, but almost entirely rewritten to actually work with MusicBrainz's incomplete information. The only thing left intact is the `recording_` MP3 tags, for compatibility with `beets-recordingdate`.

Beets plugin that fetches oldest recording or release date for each track. This is especially useful when tracks are
from best-of compilations, remasters, or re-releases. Originally based on `beets-recordingdate` by tweitzel, but almost
entirely rewritten to actually work with MusicBrainz's incomplete information. The only thing left intact is
the `recording_` MP3 tags, for compatibility with `beets-recordingdate`.

# Installation
Simply run `pip install beets-oldestdate` then add `oldestdate` to the list of active plugins in beets and configure as necessary. The plugin is intended to be used in singleton mode. Undefined behaviour may occur otherwise.

Simply run `pip install beets-oldestdate` then add `oldestdate` to the list of active plugins in beets and configure as
necessary. The plugin is intended to be used in singleton mode. Undefined behaviour may occur otherwise.

# Configuration

Key | Default Value | Description
:-------------: |:-------------:| :-----:
auto | True | Run oldestdate during the import phase
ignore_track_id | False | During import, ignore existing track_id. Needed if using plugin on a library already tagged by MusicBrainz
filter_on_import | True | During import, weight down candidates with no work_id so you are more likely to choose a recording with a work_id
prompt_missing_work_id | True | During import, prompt to fix work_id if missing from chosen recording
force | False | Run even if `recording_` tags have already been applied to the track
overwrite_date | False | Overwrite the date MP3 tag field, inluding year, month, and day
filter_recordings | True | Skip recordings that have attributes before fetching them. This is usually live recordings
approach | releases | What approach to use to find oldest date. Possible values: `recordings, releases, hybrid, both`. `recordings` works like `beets-recordingdate` did, `releases` is a far more accurate method. Hybrid only fetches releases if no date was found in recordings.
release_types | None | Filter releases by type, e.g. `['Official']`. Usually not needed
use_file_date | False | Use the file's embedded date too when looking for the oldest date
Key | Default Value | Description
:----------------------:|:-------------:| :-----:
auto | True | Run oldestdate during the import phase
ignore_track_id | False | During import, ignore existing track_id. Needed if using plugin on a library already tagged by MusicBrainz
filter_on_import | True | During import, weight down candidates with no work_id so you are more likely to choose a recording with a work_id
prompt_missing_work_id | True | During import, prompt to fix work_id if missing from chosen recording
force | False | Run even if `recording_` tags have already been applied to the track
overwrite_date | False | Overwrite the date MP3 tag field, inluding year, month, and day
overwrite_month | True | If overwriting date, also overwrite month field, otherwise leave blank
overwrite_day | True | If overwriting date, also overwrite day, otherwise leave blank
filter_recordings | True | Skip recordings that have attributes before fetching them. This is usually live recordings
approach | releases | What approach to use to find oldest date. Possible values: `recordings, releases, hybrid, both`. `recordings` works like `beets-recordingdate` did, `releases` is a far more accurate method. Hybrid only fetches releases if no date was found in recordings.
release_types | None | Filter releases by type, e.g. `['Official']`. Usually not needed
use_file_date | False | Use the file's embedded date too when looking for the oldest date

## Optimal Configuration

musicbrainz:
searchlimit: 20
plugins: oldestdate
Expand All @@ -33,14 +42,42 @@ Simply run `pip install beets-oldestdate` then add `oldestdate` to the list of a
overwrite_date: yes
filter_recordings: yes
approach: 'releases'

## How it works
The plugin will take the recording that was chosen and get its `work_id`. From this, it gets all recordings associated with said work. If using the `recordings` approach, it will look through these recordings' dates and find the oldest. If using the `releases` approach, it will instead go through the dates for all releases for all recordings and find the oldest (*much* more accurate). The difference between these two approaches is that with `recordings` it only takes one API call to get the necessary data, while with `releases` it takes *n* calls, where *n* is the number of recordings. This takes significantly longer due to MusicBrainz's default ratelimit of 1 API call per second. Due to this, the option `filter_recordings` exists to cut down on the amount of calls needed.

The plugin will take the recording that was chosen and get its `work_id`. From this, it gets all recordings associated
with said work. If using the `recordings` approach, it will look through these recordings' dates and find the oldest. If
using the `releases` approach, it will instead go through the dates for all releases for all recordings and find the
oldest (*much* more accurate). The difference between these two approaches is that with `recordings` it only takes one
API call to get the necessary data, while with `releases` it takes *n* calls, where *n* is the number of recordings.
This takes significantly longer due to MusicBrainz's default ratelimit of 1 API call per second. Due to this, the
option `filter_recordings` exists to cut down on the amount of calls needed.

### Missing work_id
If the chosen recording has no Work associated with it, the plugin cannot do its job. This is where `filter_on_import` comes in: it applies a negative score to tracks that don't have an associated work so they are much less likely to be chosen. However, this means some of the displayed tracks will be irrelevant. Thus, setting the `searchlimit` to 20 or so tracks is needed to hit the one recording that *does* have a work. This happens to work quite well with famous songs because there is usually a single recording with an associated work that is the original recording, and thus the oldest. If we match with this one, the other recordings that we can't get to because they are not associated with the same work are irrelevant, because we already have the oldest date.

However, it sometimes happens that there is no available recording that matches our track with an associated work. This is what `prompt_missing_work_id` is for: it will prompt us to either just use the single matched recording, in which case only the matched recording's data is used, and checked against the embedded date, or we can try again, or skip the track. Trying again is so that we may go to the website and amend the data, so that the recordings will have an associated work. To help with this process, the plugin prints out a URL to a search for that specific track. Your task is to create a work and associate it with all the relevant recordings, then press try again. This can be quite a laborious task, so if we see that the date printed by the plugin as being the oldest date found with just the selected recording seems accurate, choosing `Use this recording` would be the best choice.
If the chosen recording has no Work associated with it, the plugin cannot do its job. This is where `filter_on_import`
comes in: it applies a negative score to tracks that don't have an associated work so they are much less likely to be
chosen. However, this means some of the displayed tracks will be irrelevant. Thus, setting the `searchlimit` to 20 or so
tracks is needed to hit the one recording that *does* have a work. This happens to work quite well with famous songs
because there is usually a single recording with an associated work that is the original recording, and thus the oldest.
If we match with this one, the other recordings that we can't get to because they are not associated with the same work
are irrelevant, because we already have the oldest date.

However, it sometimes happens that there is no available recording that matches our track with an associated work. This
is what `prompt_missing_work_id` is for: it will prompt us to either just use the single matched recording, in which
case only the matched recording's data is used, and checked against the embedded date, or we can try again, or skip the
track. Trying again is so that we may go to the website and amend the data, so that the recordings will have an
associated work. To help with this process, the plugin prints out a URL to a search for that specific track. Your task
is to create a work and associate it with all the relevant recordings, then press try again. This can be quite a
laborious task, so if we see that the date printed by the plugin as being the oldest date found with just the selected
recording seems accurate, choosing `Use this recording` would be the best choice.

### Covers
The plugin is also programmed to deal with covers effectively. Because a `work` actually contains both the recordings of a song by the original author and any cover artists, when the song we are processing is not a cover, any recordings tagged as covers are discarded, to save API calls. Conversely, if the processed song *is* a cover, then we only keep cover recordings, and filter them by author, so only the relevant recordings are kept. This is so the oldest date for a cover will be the oldest date in which that cover was made, and not the original song. This only works when in `releases` mode, as we need to fetch the recordings to get the author data. In `recordings` mode, all covers are treated as the same, even if they may be from different authors.

The plugin is also programmed to deal with covers effectively. Because a `work` actually contains both the recordings of
a song by the original author and any cover artists, when the song we are processing is not a cover, any recordings
tagged as covers are discarded, to save API calls. Conversely, if the processed song *is* a cover, then we only keep
cover recordings, and filter them by author, so only the relevant recordings are kept. This is so the oldest date for a
cover will be the oldest date in which that cover was made, and not the original song. This only works when
in `releases` mode, as we need to fetch the recordings to get the author data. In `recordings` mode, all covers are
treated as the same, even if they may be from different authors.
9 changes: 6 additions & 3 deletions beetsplug/oldestdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

musicbrainzngs.set_useragent(
"Beets oldestdate plugin",
"1.1.4",
'1.1.4', # Also change in pyproject.toml
"https://github.com/kernitus/beets-oldestdate"
)

Expand Down Expand Up @@ -65,6 +65,7 @@ def _is_cover(recording):
return True
return False


# Fetch work, including recording relations
def _fetch_work(work_id):
return musicbrainzngs.get_work_by_id(work_id, ['recording-rels'])['work']
Expand All @@ -84,6 +85,8 @@ def __init__(self):
'prompt_missing_work_id': True, # During import, prompt to fix work_id if missing
'force': False, # Run even if already processed
'overwrite_date': False, # Overwrite date field in tags
'overwrite_month': True, # If overwriting date, also overwrite month field
'overwrite_day': True, # If overwriting date and month, also overwrite day
'filter_recordings': True, # Skip recordings with attributes before fetching them
'approach': 'releases', # recordings, releases, hybrid, both
'release_types': None, # Filter by release type, e.g. ['Official']
Expand Down Expand Up @@ -222,8 +225,8 @@ def _process_file(self, item: Item):
'Overwriting date field for: {0.artist} - {0.title} from {0.year}-{0.month}-{0.day} to {1}-{2}-{3}',
item, year_string, month_string, day_string)
item.year = "" if oldest_date.y is None else year_string
item.month = "" if oldest_date.m is None else month_string
item.day = "" if oldest_date.d is None else day_string
item.month = "" if (oldest_date.m is None or not self.config['overwrite_month']) else month_string
item.day = "" if (oldest_date.d is None or not self.config['overwrite_day']) else day_string

self._log.info('Applying changes to {0.artist} - {0.title}', item)
item.store()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "beets-oldestdate"
version = "1.1.4"
version = "1.1.4" # Also change in oldestdate.py
authors = [
{ name="kernitus" },
]
Expand Down
5 changes: 2 additions & 3 deletions tests/test_oldestdate.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import unittest
from unittest import mock
from unittest.mock import patch, PropertyMock
from unittest.mock import patch

from beets.plugins import BeetsPlugin
from beets.library import Item

from beetsplug import oldestdate
from beetsplug.date_wrapper import DateWrapper
from beets.library import Item


class OldestDatePluginTest(unittest.TestCase):
Expand Down

0 comments on commit bbc6201

Please sign in to comment.