Repository: beetbox/beets

Issue:
Sanitize colons in drive-letter-like positions in Windows filenames like path separators
I have an album containing an artist named "F:A.R.". When doing any activity that involves moving this artist, the file is not moved to "M:\F:A.R." (I'm using unicode replacements instead of underscores), but to "M:\F:\A.R.".

### Problem

Running this command in verbose (`-vv`) mode:

```sh
~ ❯ beet -vv move "artist::^F.A.R"
user configuration: C:\Users\Gunther Schmidl\AppData\Roaming\beets\config.yaml
data directory: C:\Users\Gunther Schmidl\AppData\Roaming\beets
plugin paths:
Sending event: pluginload
lyrics: Disabling google source: no API key configured.
library database: M:\lib.db
library directory: M:\
Sending event: library_opened
Moving 1 item.
moving: M:\F꞉A․R․ ／ Muslimgauze\[1990] Manipulation (Live Extracts) ／ Death of Saint Jarnaii Singh Bhindranwaie\01-01 - Manipulation (Live Extracts).mp3
Sending event: before_item_moved
Sending event: item_moved
Sending event: database_change
Sending event: database_change
Sending event: cli_exit
```

Led to this problem:

Here's what it looks like in the database: https://i.imgur.com/Z5lvGqE.png

Here's a link to the music files that trigger the bug (if relevant):

https://www.dropbox.com/s/pastihu349z6kce/01-01%20-%20Manipulation%20%28Live%20Extracts%29.mp3?dl=0

### Setup

* OS: Windows 10 v2004
* Python version: 3.8.0
* beets version: latest trunk, but also happens in latest released version
* Turning off plugins made problem go away (yes/no): no

My configuration (output of `beet config`) is:

```yaml
lyrics:
    bing_lang_from: []
    auto: yes
    bing_client_secret: REDACTED
    bing_lang_to:
    google_API_key: REDACTED
    google_engine_ID: REDACTED
    genius_api_key: REDACTED
    fallback:
    force: no
    local: no
    sources:
    - google
    - lyricwiki
    - musixmatch
    - genius
directory: M:/
library: M:/lib.db

paths:
    default: $albumartist/[$year] $album%aunique{}/$disc-$track - $title
    singleton: Non-Album/$artist/$title
    comp: Compilations/[$year] $album%aunique{}/$disc-$track - $title

plugins: web mbsync discogs chroma lastgenre duplicates lyrics
lastgenre:
    source: artist
    force: no
    whitelist: yes
    min_weight: 10
    count: 1
    fallback:
    canonical: no
    auto: yes
    separator: ', '
    prefer_specific: no

replace:
    \\: "\uFF3C"
    /: "\uFF0F"
    \.{3}: "\u2026"
    \.: "\u2024"
    '[\x00-\x1f]': ''
    <: "\uFE64"
    '>': "\uFE65"
    ':': "\uA789"
    '"': "\uFF02"
    \?: "\uFF1F"
    \*: "\u204E"
    \|: "\u2502"
    \s+$: ''
    ^\s+: ''
    ^-: "\u2012"
path_sep_replace: "\uFF0F"
discogs:
    apikey: REDACTED
    apisecret: REDACTED
    tokenfile: discogs_token.json
    source_weight: 0.5
    user_token: REDACTED
    separator: ', '
    index_tracks: no
web:
    host: 127.0.0.1
    port: 8337
    cors: ''
    cors_supports_credentials: no
    reverse_proxy: no
    include_paths: no
duplicates:
    album: no
    checksum: ''
    copy: ''
    count: no
    delete: no
    format: ''
    full: no
    keys: []
    merge: no
    move: ''
    path: no
    tiebreak: {}
    strict: no
    tag: ''
chroma:
    auto: yes
```


Discussion:

Patch:
diff --git a/beets/config_default.yaml b/beets/config_default.yaml
index 0fd6eb592..c75778b80 100644
--- a/beets/config_default.yaml
+++ b/beets/config_default.yaml
@@ -44,6 +44,7 @@ replace:
     '^\s+': ''
     '^-': _
 path_sep_replace: _
+drive_sep_replace: _
 asciify_paths: false
 art_filename: cover
 max_filename_length: 0
diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py
index b13f2638a..46b47a2e1 100755
--- a/beets/dbcore/db.py
+++ b/beets/dbcore/db.py
@@ -19,6 +19,7 @@ from __future__ import division, absolute_import, print_function
 
 import time
 import os
+import re
 from collections import defaultdict
 import threading
 import sqlite3
@@ -84,6 +85,11 @@ class FormattedMapping(Mapping):
 
         if self.for_path:
             sep_repl = beets.config['path_sep_replace'].as_str()
+            sep_drive = beets.config['drive_sep_replace'].as_str()
+
+            if re.match(r'^\w:', value):
+                value = re.sub(r'(?<=^\w):', sep_drive, value)
+
             for sep in (os.path.sep, os.path.altsep):
                 if sep:
                     value = value.replace(sep, sep_repl)
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 0f41c38ec..64e6ab85c 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -235,6 +235,9 @@ Fixes:
 * :doc:`/plugins/ipfs`: Fix Python 3 compatibility.
   Thanks to :user:`musoke`.
   :bug:`2554`
+* Fix a bug that caused metadata starting with something resembling a drive
+  letter to be incorrectly split into an extra directory after the colon.
+  :bug:`3685`
 
 For plugin developers:
 


Test patch:
diff --git a/test/test_files.py b/test/test_files.py
index f31779672..13a8b4407 100644
--- a/test/test_files.py
+++ b/test/test_files.py
@@ -102,6 +102,25 @@ class MoveTest(_common.TestCase):
         self.i.move()
         self.assertEqual(self.i.path, old_path)
 
+    def test_move_file_with_colon(self):
+        self.i.artist = u'C:DOS'
+        self.i.move()
+        self.assertIn('C_DOS', self.i.path.decode())
+
+    def test_move_file_with_multiple_colons(self):
+        print(beets.config['replace'])
+        self.i.artist = u'COM:DOS'
+        self.i.move()
+        self.assertIn('COM_DOS', self.i.path.decode())
+
+    def test_move_file_with_colon_alt_separator(self):
+        old = beets.config['drive_sep_replace']
+        beets.config["drive_sep_replace"] = '0'
+        self.i.artist = u'C:DOS'
+        self.i.move()
+        self.assertIn('C0DOS', self.i.path.decode())
+        beets.config["drive_sep_replace"] = old
+
     def test_read_only_file_copied_writable(self):
         # Make the source file read-only.
         os.chmod(self.path, 0o444)