FileNameTemplate.swift 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import Foundation
  2. /// Generates filenames from audio metadata using configurable templates.
  3. ///
  4. /// Template variables (enclosed in `{}`):
  5. /// {artist} - Track artist
  6. /// {album} - Album name
  7. /// {title} - Track title
  8. /// {genre} - Genre
  9. /// {bpm} - BPM (rounded)
  10. /// {key} - Musical key
  11. /// {year} - Release year from metadata
  12. /// {track} - Track number in playlist (zero-padded)
  13. /// {duration} - Duration as M:SS
  14. /// {format} - File format extension
  15. /// {samplerate} - Sample rate
  16. /// {bitdepth} - Bit depth
  17. ///
  18. /// Example: "{artist} - {album} - {title}" → "Raekwon - Only Built 4 Cuban Linx - Spot Rusherz"
  19. struct FileNameTemplate {
  20. /// Default template
  21. static let defaultTemplate = "{artist} - {album} - {title}"
  22. /// All available template variables with descriptions.
  23. static let availableVariables: [(token: String, description: String)] = [
  24. ("{track}", "Track number in playlist (zero-padded)"),
  25. ("{artist}", "Artist name"),
  26. ("{album}", "Album name"),
  27. ("{title}", "Track title"),
  28. ("{genre}", "Genre"),
  29. ("{bpm}", "BPM (rounded)"),
  30. ("{key}", "Musical key"),
  31. ("{year}", "Release year"),
  32. ("{duration}", "Duration (M:SS)"),
  33. ("{format}", "File format (MP3, WAV, etc.)"),
  34. ("{samplerate}", "Sample rate (e.g. 44100)"),
  35. ("{bitdepth}", "Bit depth (e.g. 16)"),
  36. ]
  37. /// Some common presets.
  38. static let presets: [(name: String, template: String)] = [
  39. ("Artist - Album - Title", "{artist} - {album} - {title}"),
  40. ("## Artist - Title", "{track} {artist} - {title}"),
  41. ("## Title", "{track} {title}"),
  42. ("Artist - Title [BPM Key]", "{artist} - {title} [{bpm} {key}]"),
  43. ("Album - ## Title", "{album} - {track} {title}"),
  44. ("Artist - Title (Format)", "{artist} - {title} ({format})"),
  45. ]
  46. /// Generate a filename from a template string and track metadata.
  47. static func generate(
  48. template: String,
  49. track: Track,
  50. playlistIndex: Int,
  51. totalTracks: Int
  52. ) -> String {
  53. let padWidth = totalTracks >= 100 ? 3 : 2
  54. var result = template
  55. result = result.replacingOccurrences(of: "{track}", with: String(format: "%0\(padWidth)d", playlistIndex + 1))
  56. result = result.replacingOccurrences(of: "{artist}", with: track.artist.isEmpty ? "Unknown Artist" : track.artist)
  57. result = result.replacingOccurrences(of: "{album}", with: track.album.isEmpty ? "Unknown Album" : track.album)
  58. result = result.replacingOccurrences(of: "{title}", with: track.title)
  59. result = result.replacingOccurrences(of: "{genre}", with: track.genre.isEmpty ? "Unknown" : track.genre)
  60. result = result.replacingOccurrences(of: "{bpm}", with: track.bpm.map { String(format: "%.0f", $0) } ?? "")
  61. result = result.replacingOccurrences(of: "{key}", with: track.musicalKey ?? "")
  62. result = result.replacingOccurrences(of: "{year}", with: track.year.map { String($0) } ?? "")
  63. result = result.replacingOccurrences(of: "{duration}", with: track.formattedDuration)
  64. result = result.replacingOccurrences(of: "{format}", with: track.fileFormat)
  65. result = result.replacingOccurrences(of: "{samplerate}", with: "\(Int(track.sampleRate))")
  66. result = result.replacingOccurrences(of: "{bitdepth}", with: "\(track.bitDepth)")
  67. // Clean up: remove double separators from empty fields, trim
  68. result = result.replacingOccurrences(of: " ", with: " ")
  69. result = result.replacingOccurrences(of: " - - ", with: " - ")
  70. result = result.replacingOccurrences(of: "- -", with: "-")
  71. result = result.replacingOccurrences(of: "[]", with: "")
  72. result = result.replacingOccurrences(of: "()", with: "")
  73. result = result.trimmingCharacters(in: .whitespaces)
  74. result = result.trimmingCharacters(in: CharacterSet(charactersIn: "- "))
  75. // Sanitize for filesystem
  76. return sanitizeFilename(result)
  77. }
  78. /// Preview what a filename would look like with a given template.
  79. static func preview(template: String) -> String {
  80. let fakeTrack = Track(
  81. title: "Spot Rusherz",
  82. artist: "Raekwon",
  83. album: "Only Built 4 Cuban Linx",
  84. genre: "Hip-Hop",
  85. filePath: "/fake.mp3",
  86. duration: 193,
  87. fileFormat: "MP3"
  88. )
  89. fakeTrack.bpm = 95
  90. fakeTrack.musicalKey = "Dm"
  91. fakeTrack.year = 1995
  92. return generate(template: template, track: fakeTrack, playlistIndex: 0, totalTracks: 18)
  93. }
  94. // MARK: - Helpers
  95. private static func sanitizeFilename(_ name: String) -> String {
  96. let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|")
  97. return name.components(separatedBy: illegal).joined(separator: "_")
  98. }
  99. }