Skip to content

ftintitle: parenthetical features are broken #6715

@treyturner

Description

@treyturner

Problem

In the ftintitle plugin, parenthetical features are split at the feature token but surrounding delimiters are left behind on both sides.

For example:

Alice (feat. Bob) - Song

becomes:

Alice ( - Song feat. Bob)

Setup

  • OS: Linux
  • Python version: 3.14
  • beets version: 2.11.0
  • Turning off plugins made problem go away (yes/no): This bug is in a plugin

My configuration (output of beet config) is:

directory: /library
id3v23: no
# --------------- Main ---------------

library: /config/library.db
original_date: yes
# --------------- Paths ---------------
path_sep_replace: ""
# --------------- Tagging ---------------
per_disc_numbering: yes
# --------------- Performance ---------------
threaded: yes
# --------------- Plugins ---------------
plugins:
  - albumtypes
  - beatport4
  - bucket
  - chroma
  - discogs
  - embedart
  - fetchart
  - filetote
  - fromfilename
  - ftintitle
  - importadded
  - importreplace
  - info
  - inline
  - lastgenre
  - lyrics
  - musicbrainz
  - permissions
  - replaygain
  - rewrite
  - scrub
  - tidal
  - the
  - web
  - zero
album_fields:
  is_music: "token = None\nvalid_libs = ['Christmas', 'Comedy', 'Field', 'Halloween', 'Kids', 'Music']\ntry:\n  token = 1 if library.title() == 'Music' else 0\nexcept NameError:\n  raise Exception(f\"You must specify a library using --set library=<library>\\nAvailable libraries: {valid_libs}\")\nreturn token\n"
  cat_token: "token = ''\nblacklist = ['none', '[none]', 'n/a', '-']\ncatnum = catalognum if catalognum.lower() not in blacklist else ''\nif len(catnum) > 0:\n  token += ' {' + catnum + '}'\nreturn token\n"
  label_token: "token = ''\nblacklist = ['none', '[none]', 'n/a', '-']\nlbl = label if label.lower() not in blacklist else ''\ncatnum = catalognum if catalognum.lower() not in blacklist else ''\nif len(lbl) + len(catnum) > 0:\n  token += ' {'\n  if len(lbl) > 0:\n    token += lbl\n    if len(catnum) > 0:\n      token += ', '\n  if len(catnum) > 0:\n    token += catnum\n  token += '}'\nreturn token\n"
  library_token: "token = None\nvalid_libs = ['Christmas', 'Comedy', 'Field', 'Halloween', 'Kids', 'Music']\ntry:\n  if library.title() in valid_libs:\n    token = library.title()\nexcept NameError:\n  raise Exception(f\"You must specify a library using --set library=<library>\\nAvailable libraries: {valid_libs}\")\nreturn token\n"
  media_token: "media_map = {\n  'Cassette': 'CAS',\n  'Vinyl': 'VNL',\n  'Blu-ray': 'BR',\n  'DVD-Audio': 'DVD',\n  'DVD-Video': 'DVD',\n  'Digital Media': 'WEB',\n  'Digital': 'WEB',\n  'File': 'WEB',\n}\n\nmedias_unsorted = []\nfor item in items:\n  m = media_map.get(item.media, item.media)\n  if m is None or len(m.strip()) < 1:\n    m = 'WEB'\n  if m not in medias_unsorted:\n    medias_unsorted.append(m)\n\nmedia_sort_order = ['SACD', 'CD', 'CAS', 'VNL', 'BR', 'DVD', 'USB', 'WEB']\nmedias = []\nfor value in media_sort_order:\n  if value in medias_unsorted:\n    medias.append(medias_unsorted.pop(medias_unsorted.index(value)))\n\nif len(medias) > 0:\n  return '+'.join(medias)\n\nif len(medias_unsorted) > 0:\n  return '+'.join(medias_unsorted)\n\nreturn 'WEB'\n"
  format_token: "formats_unsorted = []\nfor item in items:\n  if item.format not in formats_unsorted:\n    formats_unsorted.append(item.format)\nformat_sort_order = ['TRUEHD', 'DTS', 'FLAC', 'ALAC', 'APE', 'WAVPACK', 'WAV', 'OGG', 'AAC', 'MP3', 'WMA']\nformat_codecs = []\nfor fso in format_sort_order:\n  if fso in formats_unsorted:\n    format_codecs.append(fso)\nlossless_formats = ['TRUEHD', 'FLAC', 'ALAC', 'APE', 'WAVPACK', 'WAV']\nformats = []\nfor format in format_codecs:\n  if format in lossless_formats:\n    fmt_bitdepth = 0\n    fmt_samplerate = 0\n    item_count = 0\n    fmt_maxchannels = 0\n    for item in list(filter(lambda i: i.format == format, items)):\n      item_count += 1\n      fmt_maxchannels = max(fmt_maxchannels, item.channels)\n      fmt_bitdepth += item.bitdepth\n      fmt_samplerate += item.samplerate\n    if item_count > 0:\n      fmt_bitdepth = round(fmt_bitdepth / item_count)\n      fmt_samplerate = round(fmt_samplerate / item_count / 1000)\n      channel_token = ''\n      if fmt_maxchannels > 2:\n        channel_value = f'{fmt_maxchannels}.0';\n        if fmt_maxchannels == 6:\n          channel_value = '5.1'\n        elif fmt_maxchannels == 8:\n          channel_value = '7.1'\n        channel_token = f' {channel_value}'\n      ftoken = f' {format}{channel_token} {fmt_bitdepth}-{fmt_samplerate}'\n      formats.append(ftoken)\n  else:\n    fmt_avgbitrate = 0\n    item_count = 0\n    fmt_maxchannels = 0\n    for item in list(filter(lambda i: i.format == format, items)):\n      item_count += 1\n      fmt_maxchannels = max(fmt_maxchannels, item.channels)\n      fmt_avgbitrate += item.bitrate\n    if item_count > 0:\n      fmt_avgbitrate = round(fmt_avgbitrate / item_count / 1000)\n      channel_token = ''\n      if fmt_maxchannels > 2:\n        channel_value = f'{fmt_maxchannels}.0';\n        if fmt_maxchannels == 6:\n          channel_value = '5.1'\n        elif fmt_maxchannels == 8:\n          channel_value = '7.1'\n        channel_token = f' {channel_value}'\n      ftoken = f' {format}{channel_token} {fmt_avgbitrate}'\n      formats.append(ftoken)\nreturn ','.join(formats)\n"
  albumdisambig_token: '# leaving logic in case we want to put it behind a flag later

    #return '''' if not albumdisambig else f" ({albumdisambig.title()})"

    return ''''

'
albumtypes:
  types:
    - ep: ' EP'
    - single: ' (Single)'
    - soundtrack: ' OST'
    - compilation: ' (Anthology)'
  ignore_va: compilation
  bracket: ''
beatport4:
  art: yes
  art_overwrite: no
  art_width: 2160
  data_source_mismatch_penalty: 0.3
  search_limit: 5
  username: REDACTED
  password: REDACTED
  tokenfile: beatport_token.json
  client_id:
  art_height:
  singletons_with_album_metadata:
    enabled: no
    year: yes
    album: yes
    label: yes
    catalognum: yes
    albumartist: yes
    track_number: yes
bucket:
  bucket_alpha:
    - '# - D'
    - E - L
    - M - R
    - S - Z
  bucket_alpha_regex:
    '# - D': "^[!-\\/0-9:-@A-Da-d¡¢¤©ª«¬²-¿À-Çà-çÐλ]"
    E - L: "^[E-Le-lÈ-Ïè-ï£]"
    M - R: "^[M-Rm-rÑ-Öñ-öØœ®°μ]"
    S - Z: "^[S-Zs-zŠšŽžŸù-ýÿ¥§]"
  bucket_year: []
  extrapolate: no
chroma:
  auto: yes
  search_limit: 5
  data_source_mismatch_penalty: 0.5
# --------------- Import ---------------
clutter:
  - '*.cue'
  - '*.doc'
  - '*.m3u'
  - '*.m3u8'
  - '*.par'
  - '*.par2'
  - '*.pls'
  - '*.rtf'
  - '*.sfv'
  - '*.srr'
  - '*.txt'
  - .DS_Store
  - proof.*
  - Thumbs.DB
discogs:
  data_source_mismatch_penalty: 0.2
  featured_string: ft.
  search_limit: 5
  search_query_ascii: no
  apikey: REDACTED
  apisecret: REDACTED
  tokenfile: discogs_token.json
  user_token: REDACTED
  separator: ', '
  index_tracks: no
  append_style_genre: no
  strip_disambiguation: yes
  extra_tags: []
  anv:
    artist_credit: yes
    artist: no
    album_artist: no
embedart:
  auto: yes
  maxwidth: 2160
  minwidth: 2160
  quality: 90
  remove_art_file: no
  compare_threshold: 0
  ifempty: no
  clearart_on_import: no
fetchart:
  auto: yes
  enforce_ratio: yes
  fanarttv_key: REDACTED
  high_resolution: yes
  lastfm_key: REDACTED
  max_filesize: 20000000
  maxwidth: 2160
  minwidth: 2160
  quality: 90
  sources:
    - coverart: release
    - itunes
    - coverart: releasegroup
    - amazon
    - albumart
    - lastfm
    - fanarttv
    - filesystem
  store_source: yes
  cautious: no
  cover_names:
    - cover
    - front
    - art
    - album
    - folder
  fallback:
  deinterlace: no
  cover_format:
  google_key: REDACTED
  google_engine: REDACTED
filetote:
  exclude:
    filenames:
      - album.jpeg
      - album.jpg
      - album.png
      - cover.jpeg
      - cover.jpg
      - cover.png
      - folder.jpeg
      - folder.jpg
      - folder.png
      - front.jpeg
      - front.jpg
      - front.png
    extensions: ''
    patterns: {}
  extensions: .diz .jpeg .jpg .log .nfo .pdf .png .srr .txt
  paths:
    ext:.diz: $albumpath/$subpath$old_filename
    ext:.log: $albumpath/$subpath$old_filename
    ext:.nfo: $albumpath/$subpath$old_filename
    ext:.pdf: $albumpath/$subpath$old_filename
    ext:.srr: $albumpath/$subpath$old_filename
    ext:.txt: $albumpath/$subpath$old_filename
    pattern:artworkdir: $albumpath/$subpath$old_filename
  patterns:
    artworkdir: ['**/[sS]can*/']
  session:
    operation:
    _beets_lib:
    _library_path:
    import_path:
  filenames: ''
  pairing:
    enabled: no
    pairing_only: no
    extensions: .*
  print_ignored: no
  duplicate_action: merge
ftintitle:
  format: ft. {0}
  auto: yes
  drop: no
  keep_in_artist: no
  preserve_album_artist: yes
  custom_words: []
  bracket_keywords:
    - abridged
    - acapella
    - club
    - demo
    - edit
    - edition
    - extended
    - instrumental
    - live
    - mix
    - radio
    - release
    - remaster
    - remastered
    - remix
    - rmx
    - unabridged
    - unreleased
    - version
    - vip
import:
  default_action: none
  detail: no
  duplicate_action: ask
  duplicate_verbose_prompt: yes
  incremental: no
  incremental_skip_later: no
  languages: en
  log: /config/beets.log
  timid: yes
  quiet: no
  write: yes
  copy: no
  move: yes
importreplace:
  replacements:
    - item_fields: artist artist_sort artist_credit albumartist albumartist_sort albumartist_credit
      album_fields: artist artist_sort artist_credit albumartist albumartist_sort albumartist_credit
      replace:
        '[\u2010-\u2015]': '-'
        '[\u2018-\u201B]': ''''
        '[\u201C-\u201F]': '"'
    - item_fields: title album
      album_fields: album
      replace:
        '[\u2010-\u2015]': '-'
        '[\u2018-\u201B]': ''''
        '[\u201C-\u201F]': '"'
        (?i)\bRMX\b: Remix
        (?i)\bRemix\b: Remix
    - item_fields: album
      album_fields: album
      replace:
        (?i)\s+EP$: ''
        (?i)\s*\(\s*(?=[^)]*(?:Sound\s*track|Score|Songs|Music\s+From|Motion\s+Picture))[^)]*\s*\): ''
    - item_fields: albumdisambig
      album_fields: albumdisambig
      replace:
        (?i)\d\d[\s-]?Bit: ''
        (?i)\d\d[\s-]?kHz: ''
        \s+$: ''
        ^\s+: ''
        \b\s+\b: ' '
        \s*,\s*\/?\s*$: ''
        ^\s*\/?\s*,\s*: ''
    - item_fields: label catalognum
      album_fields: label catalognum
      replace:
        '[\u2010-\u2015]': '-'
        '[\u2018-\u201B]': ''''
        '[\u201C-\u201F]': '"'
        (?i)^none$: ''
        (?i)^\[none\]$: ''
        (?i)^n/a$: ''
        ^-$: ''
    - item_fields: label
      album_fields: label
      replace:
        \u00A9\s+(?:'\d{2}|\d{4})\s*: ''
        (?i)\(C\)\s+(?:'\d{2}|\d{4})\s+\s*: ''
        (?<=\bRecords\b).*$: ''
        (?<=\bLtd\b).*$: ''
        (?<=\bLtd\.\b).*$: ''
    - item_fields: media
      album_fields: media
      replace:
        '\d+" ': ''
item_fields:
  disctitle_token: "if disctotal > 1:\n  disctitle_token = f'CD{str(disc).zfill(len(str(disctotal)))}'\n  if disctitle not in (album, ''):\n    disctitle_token += f' - {disctitle}'\n  return disctitle_token\nelse:\n  return ''\n"
  track_token: "disc_token = ''\nif disctotal > 1:\n  disc_token = f'{str(disc).zfill(len(str(disctotal)))}-'\ntrack_token = str(track).zfill(max(len(str(tracktotal)) if tracktotal else 0, 2))\ntoken = f'{disc_token}{track_token}. {artist} - {title}'\nreturn token\n"
lastgenre:
  canonical: /config/genre-tree.yaml
  min_weight: 50
  prefer_specific: yes
  source: album
  whitelist: /config/genre-whitelist.txt
  count: 1
  fallback:
  cleanup_existing: no
  force: no
  keep_existing: no
  auto: yes
  title_case: yes
  pretend: no
  ignorelist: {}
lyrics:
  force: yes
  google_API_key: REDACTED
  sources: [lrclib, google]
  synced: yes
  auto: yes
  auto_ignore:
  translate:
    api_key: REDACTED
    from_languages: []
    to_language:
  dist_thresh: 0.11
  google_engine_ID: REDACTED
  genius_api_key: REDACTED
  fallback:
  keep_synced: no
  local: no
  print: no
match:
  ignored_media:
    - Data CD
    - DVD
    - DVD-Video
    - VCD
    - SVCD
    - UMD
    - VHS
  strong_rec_thresh: 0.2
  medium_rec_thresh: 0.33
  max_rec:
    missing_tracks: medium
    unmatched_tracks: medium
    year: medium
musicbrainz:
  host: 172.18.0.20:5000
  https: no
  ratelimit: 100
  extra_tags:
    - barcode
    - catalognum
    - country
    - label
    - media
    - year
  search_limit: 5
  data_source_mismatch_penalty: 0.0
  external_ids:
    discogs: yes
    spotify: yes
    bandcamp: yes
    beatport: yes
    deezer: yes
    tidal: yes
  search_query_ascii: no
  genres: no
  genres_tag: genre
paths:
  default: $library_token/%if{$is_music,%bucket{%the{$albumartist},alpha}/}%the{$albumartist}/$year-$month-$day - $album$atypes$albumdisambig_token$label_token%if{$media_token$format_token, [%if{$media_token,$media_token}%if{$format_token,$format_token}]}/%if{$multidisc,$disctitle_token/}$track_token
  singleton: $library_token/%if{$is_music,%bucket{%the{$albumartist},alpha}/}%the{$albumartist}/$year-$month-$day - $title$atypes$albumdisambig_token$label_token%if{$media_token$format_token, [%if{$media_token,$media_token}%if{$format_token,$format_token}]}/%if{$multidisc,$disctitle_token/}$track_token
  albumtype:soundtrack: $library_token/Soundtracks/%the{$albumartist} - %the{$album}$atypes$albumdisambig_token ($year)$label_token%if{$media_token$format_token, [%if{$media_token,$media_token}%if{$format_token,$format_token}]}/%if{$multidisc,$disctitle_token/}$track_token
  comp: $library_token/VA/%the{$label}/$year-$month-$day - $album$atypes$albumdisambig_token$cat_token%if{$media_token$format_token, [%if{$media_token,$media_token}%if{$format_token,$format_token}]}/%if{$multidisc,$disctitle_token/}$track_token
permissions:
  file: 660
  dir: 770
replace:
  /: ""
  \\: ""
  \?: ""
  ':': ""
  \.\.\.: ""
  ^\.: ''
  <: ""
  '>': ""
  \*: ""
  \|: ""
  '[\x00-\x1F]': ''
  \.$: ''
  \s+$: ''
  '"': ""
  '[\u2010-\u2015]': '-'
  '[\u2018-\u2019]': ''''
  "": ''''
  "": ''''
  '[\u201C-\u201D]': ""
  "": ""
  "": ""
  '[\uFE4D-\uFE4F]': _
  "_": _
replaygain:
  backend: ffmpeg
  overwrite: no
  auto: yes
  threads: 32
  parallel_on_import: no
  per_disc: no
  peak: 'true'
  targetlevel: 89
  r128: [Opus]
  r128_targetlevel: 84
scrub:
  auto: yes
tidal:
  client_id: REDACTED
  data_source_mismatch_penalty: 0.5
  search_limit: 5
  tokenfile: tidal_token.json
web:
  host: 0.0.0.0
  port: 8337
  cors: ''
  cors_supports_credentials: no
  reverse_proxy: no
  include_paths: no
  readonly: yes
zero:
  auto: yes
  update_database: yes
  fields: mb_albumartistid mb_albumartistids mb_albumid mb_artistid mb_artistids mb_releasegroupid mb_releasetrackid mb_trackid mb_workid
  mb_albumartistid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_albumartistids: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_albumid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_artistid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_artistids: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_releasegroupid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_releasetrackid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_trackid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  mb_workid: ['^(?![0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}).*$']
  keep_fields: []
  omit_single_disc: no
disabled_plugins: []
importadded:
  preserve_mtimes: no
  preserve_write_mtimes: no
pathfields: {}
rewrite: {}
the:
  the: yes
  a: yes
  format: '{}, {}'
  strip: no
  patterns: []

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions