├── .gitattributes ├── .gitignore ├── README.md ├── TeslaTags.Gui ├── App.config ├── App.xaml ├── App.xaml.cs ├── Icons │ ├── AllSizes.ico │ ├── Artboard 1128.png │ ├── Artboard 116.png │ ├── Artboard 124.png │ ├── Artboard 132.png │ ├── Artboard 148.png │ ├── Artboard 164.png │ ├── noun_464493_cc.ai │ └── noun_464493_cc.svg ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── Properties │ └── AssemblyInfo.cs ├── Services │ ├── IConfigurationService.cs │ ├── IDialogService.cs │ ├── ILiveConfigurationService.cs │ └── WindowService.cs ├── TeslaTags.Gui.csproj ├── ViewModel │ ├── BaseViewModel.cs │ ├── DesignTeslaTagService.cs │ ├── DirectoryViewModel.Properties.cs │ ├── DirectoryViewModel.cs │ ├── GenreRulesViewModel.cs │ ├── MainViewModel.Properties.cs │ ├── MainViewModel.cs │ └── ViewModelLocator.cs ├── Views │ └── DataGridBehavior.AutoScroll.cs ├── app.manifest └── packages.config ├── TeslaTags.QuickFix ├── App.config ├── Program.cs ├── Properties │ └── AssemblyInfo.cs ├── TeslaTags.QuickFix.csproj └── packages.config ├── TeslaTags.Tests ├── DiscTrackNumberTests.cs ├── Properties │ └── AssemblyInfo.cs ├── TeslaTags.Tests.csproj └── packages.config ├── TeslaTags.sln ├── TeslaTags ├── App.config ├── Extensions.cs ├── Files │ ├── FlacLoadedFile.cs │ ├── GenericId3LoadedFile.cs │ ├── LoadedFile.cs │ ├── Mp4LoadedFile.cs │ ├── MpegLoadedFile.cs │ ├── OggLoadedFile.cs │ ├── RiffLoadedFile.cs │ └── TagLibLoadedFile.cs ├── Program.cs ├── Properties │ └── AssemblyInfo.cs ├── Services │ ├── IDirectoryPredicate.cs │ ├── ITeslaTagService.cs │ ├── Pocos │ │ ├── DirectoryResult.cs │ │ ├── Message.cs │ │ └── MessageExtensions.cs │ ├── RealTeslaTagService.cs │ ├── RealTeslaTagUtilityService.cs │ └── TeslaTagService │ │ ├── DiscAndTrackNumberHelper.cs │ │ ├── GenreRules.cs │ │ ├── RecoveryTag.cs │ │ ├── Retagger.cs │ │ ├── RetaggingOptions.cs │ │ ├── TagExtensions.cs │ │ ├── TagWriter.cs │ │ └── TeslaTagFolderProcessor.cs ├── TagExperiments.cs ├── TeslaTags.csproj ├── Values.cs └── packages.config └── packages-other └── ShellFileDialogs ├── ShellFileDialogs.dll └── ShellFileDialogs.pdb /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc -------------------------------------------------------------------------------- /TeslaTags.Gui/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /TeslaTags.Gui/App.xaml: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /TeslaTags.Gui/App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Text; 4 | using System.Windows; 5 | 6 | using CommonServiceLocator; 7 | 8 | using GalaSoft.MvvmLight; 9 | using GalaSoft.MvvmLight.Ioc; 10 | 11 | namespace TeslaTags.Gui 12 | { 13 | public static class Program 14 | { 15 | // This is the exact same code that the PresentationBuildTasks compiler builds. 16 | // Except with async before starting the application object, to prevent issues caused by running async code in synchronous "OnFoo" event-handlers (i.e. `OnStartup`). 17 | // It also sets-up IoC before any WPF code starts too. 18 | [STAThread] 19 | //public static async Task Main() 20 | public static Int32 Main(String[] args) 21 | { 22 | AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; 23 | 24 | // https://stackoverflow.com/questions/50901586/how-do-i-resume-on-the-entry-thread-in-an-async-main?noredirect=1#comment88808169_50901586 25 | 26 | #region Attempts at running async code before WPF: 27 | #if NEVER 28 | 29 | //AsyncContext.Run( XmlDocumentConfigurationService.Instance.LoadConfigAsync ); 30 | 31 | //System.Threading.SynchronizationContext.SetSynchronizationContext( ) 32 | 33 | Int32 th0 = Thread.CurrentThread.ManagedThreadId; 34 | SynchronizationContext th0Context = SynchronizationContext.Current; 35 | 36 | Dispatcher dispatcher = Dispatcher.CurrentDispatcher; 37 | DispatcherSynchronizationContext context = new DispatcherSynchronizationContext( dispatcher ); 38 | SynchronizationContext.SetSynchronizationContext( context ); 39 | 40 | SynchronizationContext th1Context = SynchronizationContext.Current; 41 | 42 | await XmlDocumentConfigurationService.Instance.LoadConfigAsync(); 43 | 44 | Int32 th2 = Thread.CurrentThread.ManagedThreadId; 45 | SynchronizationContext th2Context = SynchronizationContext.Current; 46 | 47 | #endif 48 | #endregion 49 | 50 | XmlDocumentConfigurationService.Instance.LoadConfig(); 51 | 52 | //////////////////// 53 | 54 | RegisterDependencies( SimpleIoc.Default ); 55 | 56 | //////////////////// 57 | 58 | // Then call into WPF's PresentationBuildTasks-generated Main: 59 | TeslaTagsApplication.Main(); 60 | 61 | return 0; 62 | } 63 | 64 | private static void CurrentDomain_UnhandledException(Object sender, UnhandledExceptionEventArgs e) 65 | { 66 | StringBuilder sb = new StringBuilder(); 67 | sb.AppendLine( "AppDomain Unhandled exception:" ); 68 | 69 | Exception ex = e.ExceptionObject as Exception; 70 | while( ex != null ) 71 | { 72 | sb.AppendLine( ex.Message ); 73 | sb.AppendLine( ex.GetType().FullName ); 74 | sb.AppendLine( ex.StackTrace ); 75 | sb.AppendLine(); 76 | 77 | ex = ex.InnerException; 78 | } 79 | 80 | String message = sb.ToString(); 81 | 82 | try 83 | { 84 | using( EventLog eventLog = new EventLog( "Application" ) ) 85 | { 86 | eventLog.Source = "Application"; 87 | eventLog.WriteEntry( message, EventLogEntryType.Error, eventID: 101, category: 1 ); 88 | } 89 | } 90 | catch 91 | { 92 | // er...? 93 | } 94 | } 95 | 96 | private static void RegisterDependencies(SimpleIoc ioc) 97 | { 98 | ServiceLocator.SetLocatorProvider( () => ioc ); 99 | 100 | ioc.Register( () => XmlDocumentConfigurationService.Instance ); 101 | 102 | IConfigurationService configService = ioc.GetInstance(); 103 | 104 | if( ViewModelBase.IsInDesignModeStatic || configService.Config.DesignMode ) 105 | { 106 | // https://olitee.com/2015/01/mvvmlight-simpleioc-design-time-error/ 107 | if( !ioc.IsRegistered() ) 108 | { 109 | ioc.Register(); 110 | } 111 | } 112 | else 113 | { 114 | // Create run time view services and models 115 | ioc.Register(); 116 | } 117 | 118 | ioc.Register(); 119 | 120 | ioc.Register(); 121 | 122 | ioc.Register(); 123 | } 124 | } 125 | 126 | public partial class TeslaTagsApplication : Application 127 | { 128 | static TeslaTagsApplication() 129 | { 130 | GalaSoft.MvvmLight.Threading.DispatcherHelper.Initialize(); 131 | } 132 | 133 | /* 134 | protected override async void OnStartup(StartupEventArgs e) 135 | { 136 | RegisterDependencies(); 137 | 138 | // https://stackoverflow.com/questions/49701102/wpf-app-run-async-task-before-opening-window 139 | 140 | IConfigurationService configService = SimpleIoc.Default.GetInstance(); 141 | await configService.LoadConfigAsync(); 142 | 143 | base.OnStartup( e ); 144 | }*/ 145 | 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/AllSizes.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/AllSizes.ico -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 1128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 1128.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 116.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 116.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 124.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 124.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 132.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 132.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 148.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 148.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/Artboard 164.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/Artboard 164.png -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/noun_464493_cc.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/TeslaTags.Gui/Icons/noun_464493_cc.ai -------------------------------------------------------------------------------- /TeslaTags.Gui/Icons/noun_464493_cc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /TeslaTags.Gui/MainWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Windows; 5 | using System.Windows.Controls.Primitives; 6 | using System.Windows.Data; 7 | using System.Windows.Interop; 8 | using System.Windows.Navigation; 9 | 10 | using GalaSoft.MvvmLight.Ioc; 11 | 12 | namespace TeslaTags.Gui 13 | { 14 | public partial class MainWindow : Window 15 | { 16 | private MainViewModel ViewModel => (MainViewModel)this.DataContext; 17 | 18 | public MainWindow() 19 | { 20 | this.InitializeComponent(); 21 | 22 | this.browseButton.Click += this.BrowseButton_Click; 23 | 24 | this.Loaded += this.MainWindow_Loaded; 25 | this.Closing += this.MainWindow_Closing; 26 | 27 | // Nudge the popup targets: // https://stackoverflow.com/questions/1600218/how-can-i-move-a-wpf-popup-when-its-anchor-element-moves 28 | // The main fix in that QA is for Popups inside a UserControl - as they're in a Window we can access events directly: 29 | this.LocationChanged += this.WindowRectangleChange; 30 | this.SizeChanged += this.WindowRectangleChange; 31 | 32 | //this.DataContextChanged += this.MainWindow_DataContextChanged; 33 | } 34 | 35 | private void WindowRectangleChange( Object sender, EventArgs e ) 36 | { 37 | Double offset = this.excludePopup.HorizontalOffset; 38 | this.excludePopup.HorizontalOffset = offset + 1; // Trigger reflow 39 | this.excludePopup.HorizontalOffset = offset; // ...but don't make it a visual change. 40 | 41 | offset = this.genrePopup.HorizontalOffset; 42 | this.genrePopup.HorizontalOffset = offset + 1; 43 | this.genrePopup.HorizontalOffset = offset; 44 | } 45 | 46 | #region Window Events 47 | 48 | // HACK: Using events to avoid taking a dependency on the Blend SDK, as it's a simple application: 49 | private void MainWindow_Loaded(Object sender, RoutedEventArgs e) 50 | { 51 | this.cvs = (CollectionViewSource)this.FindResource( "directoriesProgressCvs" ); 52 | 53 | this.ViewModel.WindowLoadedCommand.Execute( parameter: null ); 54 | } 55 | 56 | private void MainWindow_Closing(Object sender, System.ComponentModel.CancelEventArgs e) 57 | { 58 | this.ViewModel.WindowClosingCommand.Execute( parameter: null ); 59 | } 60 | 61 | #endregion 62 | 63 | private void BrowseButton_Click(Object sender, RoutedEventArgs e) 64 | { 65 | WindowInteropHelper helper = new WindowInteropHelper( this ); 66 | IntPtr hWnd = helper.Handle; 67 | 68 | String path = SimpleIoc.Default.GetInstance().ShowFolderBrowseDialog( hWnd, "Browse for root of Tesla music collection directory structure." ); 69 | 70 | if( Directory.Exists( path ) ) 71 | { 72 | this.ViewModel.DirectoryPath = path; 73 | } 74 | } 75 | 76 | private void Hyperlink_RequestNavigate(Object sender, RequestNavigateEventArgs e) 77 | { 78 | if( e.Uri != null ) 79 | { 80 | try 81 | { 82 | Process p = Process.Start( e.Uri.ToString() ); 83 | if( p != null ) p.Dispose(); 84 | } 85 | catch 86 | { 87 | } 88 | } 89 | } 90 | 91 | private void AlbumArtBrowseButton_Click(Object sender, RoutedEventArgs e) 92 | { 93 | DirectoryViewModel dvm = this.ViewModel.SelectedDirectory; 94 | 95 | WindowInteropHelper helper = new WindowInteropHelper( this ); 96 | IntPtr hWnd = helper.Handle; 97 | 98 | String path = SimpleIoc.Default.GetInstance().ShowFileOpenDialogForImages( hWnd, "Browse for new album art image.", dvm.FullDirectoryPath ); 99 | 100 | if( Directory.Exists( path ) ) 101 | { 102 | if( path.StartsWith( dvm.FullDirectoryPath, StringComparison.OrdinalIgnoreCase ) ) 103 | { 104 | path = path.Substring( dvm.FullDirectoryPath.Length ); 105 | } 106 | 107 | this.ViewModel.SelectedDirectory.SelectedImageFileName = path; 108 | } 109 | } 110 | 111 | private CollectionViewSource cvs; 112 | 113 | // Remember, WPF has separate `Checked` and `Unchecked` events, not just a single `CheckedChanged` event like WinForms. 114 | // BTW - this still causes incorrect DataGrid scrollbar calculations resulting in a scrollbar thumb that changes height as you drag it 115 | // The only *trivial* solution is to switch to pixel-based scrolling instead of row-item-based scrolling: http://wpfthoughts.blogspot.com/2014/05/datagrid-vertical-scrolling-issues.html 116 | // ...but is there a way to get both - so it does item height calculations for scrolling based on pixels but the scroll-stops are snapped to each row-item? (i.e. support for variable-height rows)? TODO. 117 | private void DirectoryFilterCheckedChanged( Object sender, RoutedEventArgs e ) 118 | { 119 | if( this.cvs == null ) return; 120 | 121 | this.cvs.View.Refresh(); 122 | } 123 | 124 | // This method can be set as `this.csv.View.Filter = DataGridFilter` but it's unclear what the *best* way to do that is given `this.csv.View` could be recreated... I think? 125 | private Boolean DataGridFilter( Object item ) 126 | { 127 | DirectoryViewModel dvm = (DirectoryViewModel)item; 128 | 129 | Boolean isChecked = this.boringFilterCheckbox.IsChecked ?? false; 130 | if( isChecked ) 131 | { 132 | // TODO: Consider moving this logic into DirectoryViewModel directly. 133 | Boolean isBoring = 134 | ( dvm.FolderType == FolderType.Empty ) 135 | || 136 | ( dvm.FilesModifiedProposed == 0 && dvm.InfoCount == 0 && dvm.WarnCount == 0 && dvm.ErrorCount == 0 ); 137 | 138 | return !isBoring; 139 | } 140 | else 141 | { 142 | return true; // Include all rows. 143 | } 144 | } 145 | 146 | // ...alternatively, this alternate approach is provided: 147 | private void CollectionViewSource_Filter( Object sender, FilterEventArgs e ) 148 | { 149 | e.Accepted = this.DataGridFilter( e.Item ); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Resources; 3 | using System.Runtime.CompilerServices; 4 | using System.Runtime.InteropServices; 5 | using System.Windows; 6 | 7 | // General Information about an assembly is controlled through the following 8 | // set of attributes. Change these attribute values to modify the information 9 | // associated with an assembly. 10 | [assembly: AssemblyTitle( "TeslaTags.Gui" )] 11 | [assembly: AssemblyDescription( "" )] 12 | [assembly: AssemblyConfiguration( "" )] 13 | [assembly: AssemblyCompany( "" )] 14 | [assembly: AssemblyProduct( "TeslaTags.Gui" )] 15 | [assembly: AssemblyCopyright( "Copyright © 2018" )] 16 | [assembly: AssemblyTrademark( "" )] 17 | [assembly: AssemblyCulture( "" )] 18 | 19 | // Setting ComVisible to false makes the types in this assembly not visible 20 | // to COM components. If you need to access a type in this assembly from 21 | // COM, set the ComVisible attribute to true on that type. 22 | [assembly: ComVisible( false )] 23 | 24 | //In order to begin building localizable applications, set 25 | //CultureYouAreCodingWith in your .csproj file 26 | //inside a . For example, if you are using US english 27 | //in your source files, set the to en-US. Then uncomment 28 | //the NeutralResourceLanguage attribute below. Update the "en-US" in 29 | //the line below to match the UICulture setting in the project file. 30 | 31 | //[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)] 32 | 33 | 34 | [assembly: ThemeInfo( 35 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located 36 | //(used if a resource is not found in the page, 37 | // or application resource dictionaries) 38 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located 39 | //(used if a resource is not found in the page, 40 | // app, or any theme specific resource dictionaries) 41 | )] 42 | 43 | 44 | // Version information for an assembly consists of the following four values: 45 | // 46 | // Major Version 47 | // Minor Version 48 | // Build Number 49 | // Revision 50 | // 51 | // You can specify all the values or you can default the Build and Revision Numbers 52 | // by using the '*' as shown below: 53 | // [assembly: AssemblyVersion("1.0.*")] 54 | [assembly: AssemblyVersion( "1.0.*" )] 55 | //[assembly: AssemblyFileVersion( "1.0.0.0" )] 56 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Services/IConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Xml.Linq; 7 | 8 | namespace TeslaTags.Gui 9 | { 10 | public interface IConfigurationService 11 | { 12 | //Task LoadConfigAsync(); 13 | void LoadConfig(); 14 | 15 | Config Config { get; } 16 | 17 | //Task SaveConfigAsync(Config config); 18 | void SaveConfig(Config config); 19 | } 20 | 21 | public class Config 22 | { 23 | public Boolean DesignMode { get; set; } 24 | 25 | public String RootDirectory { get; set; } 26 | 27 | public String[] ExcludeList { get; set; } 28 | public String[] FileExtensions { get; set; } 29 | 30 | public Boolean HideEmptyDirectories { get; set; } 31 | 32 | public GenreRules GenreRules { get; set; } 33 | 34 | public (Int32 X, Int32 Y, Int32 Width, Int32 Height) RestoredWindowPosition { get; set; } 35 | 36 | public Boolean IsMaximized { get; set; } 37 | } 38 | 39 | public class XmlDocumentConfigurationService : IConfigurationService 40 | { 41 | private XmlDocumentConfigurationService() 42 | { 43 | } 44 | 45 | public static XmlDocumentConfigurationService Instance { get; } = new XmlDocumentConfigurationService(); 46 | 47 | private static readonly Char[] _directoryListSeparators = new Char[] { ';' }; 48 | 49 | private Config config; 50 | public Config Config 51 | { 52 | get 53 | { 54 | if( this.config == null ) throw new InvalidOperationException( "Configuration hasn't been loaded." ); 55 | return this.config; 56 | } 57 | } 58 | 59 | public void LoadConfig() 60 | { 61 | Config config = new Config(); 62 | 63 | // Set defaults: 64 | config.ExcludeList = FileSystemPredicate.DefaultExcludeFolders.ToArray(); 65 | config.FileExtensions = FileSystemPredicate.DefaultAudioFileExtensions.ToArray(); 66 | 67 | XDocument doc = OpenAppConfig(); 68 | if( doc != null ) 69 | { 70 | PopulateConfig( config, doc ); 71 | } 72 | 73 | this.config = config; 74 | } 75 | 76 | private static void PopulateConfig(Config config, XDocument doc) 77 | { 78 | Boolean E(String key, String name) => String.Equals( key, name, StringComparison.OrdinalIgnoreCase ); 79 | 80 | var appSettings = doc 81 | .Elements( "configuration" ) // `Elements(XName)` is immediate children, unlike `Descendants(XName)` which does a deep search. 82 | .Elements( "appSettings" ) 83 | .Elements( "add" ) 84 | .Select( el => (key: el.Attribute("key")?.Value, value: el.Attribute("value")?.Value ) ) 85 | .Where( t => !String.IsNullOrWhiteSpace( t.key ) ); 86 | 87 | config.GenreRules = new GenreRules(); 88 | 89 | foreach( (String key, String value) in appSettings ) 90 | { 91 | if( E( key, nameof(TeslaTags.Gui.Config.DesignMode) ) ) 92 | { 93 | if( Boolean.TryParse( value, out Boolean designModeValue ) ) config.DesignMode = designModeValue; 94 | } 95 | else if( E( key, nameof(TeslaTags.Gui.Config.HideEmptyDirectories) ) ) 96 | { 97 | if( Boolean.TryParse( value, out Boolean hideEmptyDirectoriesValue ) ) config.HideEmptyDirectories = hideEmptyDirectoriesValue; 98 | } 99 | else if( E( key, nameof(TeslaTags.Gui.Config.RestoredWindowPosition) ) ) 100 | { 101 | IList values = value 102 | .Split( _directoryListSeparators, StringSplitOptions.RemoveEmptyEntries ) 103 | .Select( s => Int32.TryParse( s, NumberStyles.Integer, CultureInfo.InvariantCulture, out Int32 v ) ? (Int32?)v : null ) 104 | .Where( v => v != null ) 105 | .Select( v => v.Value ) 106 | .ToList(); 107 | 108 | if( values.Count == 4 ) config.RestoredWindowPosition = ( X: values[0], Y: values[1], Width: values[2], Height: values[3] ); 109 | } 110 | else if( E( key, nameof(TeslaTags.Gui.Config.IsMaximized) ) ) 111 | { 112 | if( Boolean.TryParse( value, out Boolean isMaximizedValue ) ) config.IsMaximized = isMaximizedValue; 113 | } 114 | else if( E( key, nameof(TeslaTags.Gui.Config.ExcludeList) ) ) 115 | { 116 | String[] excludeList = value?.Split( _directoryListSeparators, StringSplitOptions.RemoveEmptyEntries ) ?? null; 117 | if( excludeList != null && excludeList.Length > 0 ) config.ExcludeList = excludeList; 118 | } 119 | else if( E( key, nameof(TeslaTags.Gui.Config.FileExtensions) ) ) 120 | { 121 | String[] extensionList = value?.Split( _directoryListSeparators, StringSplitOptions.RemoveEmptyEntries ) ?? null; 122 | if( extensionList != null && extensionList.Length > 0 ) config.FileExtensions = extensionList; 123 | } 124 | else if( E( key, nameof(TeslaTags.Gui.Config.RootDirectory) ) ) 125 | { 126 | config.RootDirectory = value; 127 | } 128 | 129 | // Genre rules: 130 | 131 | else if( E( key, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.AssortedFilesAction) ) ) // "_2" because the config schema is different than how it was prior to Release 7. 132 | { 133 | if( Enum.TryParse( value, out AssortedFilesGenreAction genreActionValue ) ) config.GenreRules.AssortedFilesAction = genreActionValue; 134 | } 135 | else if( E( key, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAlbumWithGuestArtistsAction) ) ) 136 | { 137 | if( Enum.TryParse( value, out GenreAction genreActionValue ) ) config.GenreRules.ArtistAlbumWithGuestArtistsAction = genreActionValue; 138 | } 139 | else if( E( key, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAssortedAction) ) ) 140 | { 141 | if( Enum.TryParse( value, out GenreAction genreActionValue ) ) config.GenreRules.ArtistAssortedAction = genreActionValue; 142 | } 143 | else if( E( key, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAlbumAction) ) ) 144 | { 145 | if( Enum.TryParse( value, out GenreAction genreActionValue ) ) config.GenreRules.ArtistAlbumAction = genreActionValue; 146 | } 147 | else if( E( key, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.CompilationAlbumAction) ) ) 148 | { 149 | if( Enum.TryParse( value, out GenreAction genreActionValue ) ) config.GenreRules.CompilationAlbumAction = genreActionValue; 150 | } 151 | } 152 | 153 | if( config.ExcludeList == null ) config.ExcludeList = new String[0]; 154 | } 155 | 156 | private static String AppConfigFileName => AppDomain.CurrentDomain.SetupInformation.ConfigurationFile; 157 | 158 | private static XDocument OpenAppConfig() 159 | { 160 | if( !File.Exists( AppConfigFileName ) ) return null; 161 | 162 | XDocument doc = XDocument.Load( AppConfigFileName, LoadOptions.PreserveWhitespace ); 163 | return doc; 164 | } 165 | 166 | public void SaveConfig(Config config) 167 | { 168 | XDocument doc = OpenAppConfig(); 169 | if( doc == null ) doc = new XDocument(); 170 | 171 | XElement appSettingsElement = doc 172 | .Elements( "configuration" ) // `Elements(XName)` is immediate children, unlike `Descendants(XName)` which does a deep search. 173 | .Elements( "appSettings" ) 174 | .SingleOrDefault(); 175 | 176 | if( appSettingsElement == null ) 177 | { 178 | XElement configurationElement = doc.Elements("configuration").SingleOrDefault(); 179 | if( configurationElement == null ) 180 | { 181 | configurationElement = new XElement("configuration"); 182 | doc.AddFirst( configurationElement ); 183 | } 184 | 185 | appSettingsElement = new XElement( "appSettings" ); 186 | configurationElement.Add( appSettingsElement ); 187 | } 188 | 189 | Dictionary appSettingsDict = appSettingsElement 190 | .Elements( "add" ) 191 | .Select( el => ( key: el.Attribute("key")?.Value, element: el ) ) 192 | .Where( t => !String.IsNullOrWhiteSpace( t.key ) ) 193 | .ToDictionary( t => t.key, t => t.element, StringComparer.OrdinalIgnoreCase ); 194 | 195 | //////////////////////// 196 | 197 | String restoredWindowPositionValue = String.Format( 198 | CultureInfo.InvariantCulture, 199 | "{0};{1};{2};{3}", 200 | config.RestoredWindowPosition.X, 201 | config.RestoredWindowPosition.Y, 202 | config.RestoredWindowPosition.Width, 203 | config.RestoredWindowPosition.Height 204 | ); 205 | 206 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.DesignMode) , config.DesignMode.ToString() ); 207 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.HideEmptyDirectories) , config.HideEmptyDirectories.ToString() ); 208 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.RestoredWindowPosition), restoredWindowPositionValue ); 209 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.IsMaximized) , config.IsMaximized.ToString() ); 210 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.ExcludeList) , String.Join( ";", config.ExcludeList .Where( s => !String.IsNullOrWhiteSpace(s) ).OrderBy( s => s ) ) ); 211 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.FileExtensions) , String.Join( ";", config.FileExtensions.Where( s => !String.IsNullOrWhiteSpace(s) ).OrderBy( s => s ) ) ); 212 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.RootDirectory) , config.RootDirectory ); 213 | 214 | // Genre rules, version 2 (Release 7+) 215 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.AssortedFilesAction ), config.GenreRules.AssortedFilesAction .ToString() ); 216 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAlbumWithGuestArtistsAction), config.GenreRules.ArtistAlbumWithGuestArtistsAction.ToString() ); 217 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAssortedAction ), config.GenreRules.ArtistAssortedAction .ToString() ); 218 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.ArtistAlbumAction ), config.GenreRules.ArtistAlbumAction .ToString() ); 219 | SetAppSetting( appSettingsElement, appSettingsDict, nameof(TeslaTags.Gui.Config.GenreRules) + "_2" + nameof(TeslaTags.Gui.Config.GenreRules.CompilationAlbumAction ), config.GenreRules.CompilationAlbumAction .ToString() ); 220 | 221 | //////////////////////// 222 | 223 | doc.Save( AppConfigFileName ); 224 | 225 | // https://stackoverflow.com/questions/18500419/how-to-change-number-of-characters-used-for-indentation-when-writing-xml-with-xd 226 | // - this doesn't seem to work well? 227 | /*XmlWriterSettings settings = new XmlWriterSettings(); 228 | settings.Indent = true; 229 | settings.IndentChars = "\t"; 230 | 231 | using( XmlWriter writer = XmlWriter.Create( AppConfigFileName + ".xml", settings ) ) 232 | { 233 | doc.Save( writer ); 234 | }*/ 235 | } 236 | 237 | private static void SetAppSetting(XElement appSettingsElement, Dictionary appSettingsDict, String key, String value) 238 | { 239 | XElement appConfigElement = GetAppConfigKeyValueElement( appSettingsDict, key, appSettingsElement ); 240 | 241 | appConfigElement.SetAttributeValue( "value", value ); 242 | } 243 | 244 | private static XElement GetAppConfigKeyValueElement(Dictionary dict, String key, XElement appSettingsElement) 245 | { 246 | if( dict.TryGetValue( key, out XElement value ) ) 247 | { 248 | return value; 249 | } 250 | else 251 | { 252 | value = new XElement("add"); 253 | value.SetAttributeValue( "key", key ); 254 | dict.Add( key, value ); 255 | 256 | appSettingsElement.Add( new XText( "\r\n\t\t" ) ); 257 | appSettingsElement.Add( value ); 258 | 259 | return value; 260 | } 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Services/IDialogService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using ShellFileDialogs; 4 | 5 | namespace TeslaTags.Gui 6 | { 7 | public interface IFileDialogService 8 | { 9 | String ShowFolderBrowseDialog(IntPtr hWnd, String title); 10 | 11 | String ShowFileOpenDialogForImages(IntPtr hWnd, String title, String initialDirectory); 12 | } 13 | 14 | public class ComFileDialogService : IFileDialogService 15 | { 16 | public String ShowFolderBrowseDialog(IntPtr hWnd, String title) 17 | { 18 | String path = FolderBrowserDialog.ShowDialog( hWnd, title, null ); 19 | return path; 20 | } 21 | 22 | private static readonly Filter[] _imagesFilters = new Filter[] 23 | { 24 | new Filter( "Images", "jpg", "jpeg", "png", "bmp", "gif" ), 25 | new Filter( "All files", "*" ), 26 | }; 27 | 28 | public String ShowFileOpenDialogForImages(IntPtr hWnd, String title, String initialDirectory) 29 | { 30 | String path = FileOpenDialog.ShowSingleSelectDialog( hWnd, title, initialDirectory, defaultFileName: null, filters: _imagesFilters, selectedFilterIndex: -1 ); 31 | return path; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Services/ILiveConfigurationService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace TeslaTags.Gui 8 | { 9 | /// Gets "live" configuration values directly from the UI, even if they aren't saved yet. 10 | public interface ILiveConfigurationService 11 | { 12 | FileSystemPredicate CreateFileSystemPredicate(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /TeslaTags.Gui/Services/WindowService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Windows; 4 | 5 | namespace TeslaTags.Gui 6 | { 7 | public interface IWindowService 8 | { 9 | Window GetWindowByDataContext(Object dataContext); 10 | 11 | void ShowMessageBoxWarningDialog(Object dataContext, String title, String message); 12 | 13 | void ShowMessageBoxErrorDialog(Object dataContext, String title, String message); 14 | } 15 | 16 | public class WindowService : IWindowService 17 | { 18 | public Window GetWindowByDataContext(Object dataContext) 19 | { 20 | return Application.Current.Windows 21 | .Cast() 22 | .SingleOrDefault( w => w.DataContext == dataContext ); 23 | } 24 | 25 | public void ShowMessageBoxWarningDialog(Object dataContext, String title, String message) 26 | { 27 | Window window = this.GetWindowByDataContext( dataContext ); 28 | 29 | MessageBox.Show( 30 | owner : window, 31 | messageBoxText: message, 32 | caption : title, 33 | button : MessageBoxButton.OK, 34 | icon : MessageBoxImage.Warning, 35 | defaultResult : MessageBoxResult.OK, 36 | options : MessageBoxOptions.None 37 | ); 38 | } 39 | 40 | public void ShowMessageBoxErrorDialog(Object dataContext, String title, String message) 41 | { 42 | Window window = this.GetWindowByDataContext( dataContext ); 43 | 44 | MessageBox.Show( 45 | owner : window, 46 | messageBoxText: message, 47 | caption : title, 48 | button : MessageBoxButton.OK, 49 | icon : MessageBoxImage.Error, 50 | defaultResult : MessageBoxResult.OK, 51 | options : MessageBoxOptions.None 52 | ); 53 | } 54 | 55 | // This function was intended for binding to the WPF Window size+position+state, but it's easier to do it directly without binding. 56 | /* 57 | private static void CreateBinding( INotifyPropertyChanged source, String sourcePropertyPath, DependencyObject target, DependencyProperty targetProperty, BindingMode mode = BindingMode.TwoWay ) 58 | { 59 | Binding binding = new Binding(); 60 | binding.Source = source; 61 | binding.Path = new PropertyPath( sourcePropertyPath ); 62 | binding.Mode = mode; 63 | binding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged; 64 | 65 | BindingOperations.SetBinding( target, targetProperty, binding ); 66 | }*/ 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /TeslaTags.Gui/TeslaTags.Gui.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {624055F2-0408-49EA-A6C3-F59EE7DBA1CF} 8 | WinExe 9 | TeslaTags.Gui 10 | TeslaTags.Gui 11 | v4.7.1 12 | 512 13 | {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 14 | 4 15 | true 16 | 17 | 18 | AnyCPU 19 | true 20 | full 21 | false 22 | bin\Debug\ 23 | DEBUG;TRACE 24 | prompt 25 | 4 26 | 7.3 27 | false 28 | AllRules.ruleset 29 | 30 | 31 | AnyCPU 32 | pdbonly 33 | true 34 | bin\Release\ 35 | TRACE 36 | prompt 37 | 4 38 | 39 | 40 | Icons\AllSizes.ico 41 | 42 | 43 | TeslaTags.Gui.Program 44 | 45 | 46 | app.manifest 47 | 48 | 49 | 50 | ..\packages\CommonServiceLocator.2.0.3\lib\net47\CommonServiceLocator.dll 51 | 52 | 53 | ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.dll 54 | 55 | 56 | ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Extras.dll 57 | 58 | 59 | ..\packages\MvvmLightLibs.5.4.1\lib\net45\GalaSoft.MvvmLight.Platform.dll 60 | 61 | 62 | ..\packages-other\ShellFileDialogs\ShellFileDialogs.dll 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 4.0 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | MSBuild:Compile 80 | Designer 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | MSBuild:Compile 97 | Designer 98 | 99 | 100 | App.xaml 101 | Code 102 | 103 | 104 | MainWindow.xaml 105 | Code 106 | 107 | 108 | 109 | 110 | Code 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | {831fe5d4-250f-4f55-9934-5072f2b7cbee} 121 | TeslaTags 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/BaseViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.ComponentModel; 5 | using System.Linq; 6 | using System.Reflection; 7 | using GalaSoft.MvvmLight; 8 | using GalaSoft.MvvmLight.CommandWpf; 9 | 10 | namespace TeslaTags.Gui 11 | { 12 | public abstract class BaseViewModel : ViewModelBase 13 | { 14 | /* TODO: I don't understand why this doesn't work. 15 | protected RelayCommand CreateBusyCommand( Action action, Func additionalCanExecute, Boolean enabledWhenBusy = false ) 16 | { 17 | RelayCommand cmd; 18 | if( enabledWhenBusy ) 19 | { 20 | cmd = new RelayCommand( action, canExecute: () => this.CanExecuteWhenBusy() && additionalCanExecute() ); 21 | } 22 | else 23 | { 24 | cmd = new RelayCommand( action, canExecute: () => this.CanExecuteWhenNotBusy() && additionalCanExecute() ); 25 | } 26 | this.busyCommands.Add( cmd ); 27 | return cmd; 28 | }*/ 29 | 30 | protected RelayCommand CreateBusyCommand( Action action, Boolean enabledWhenBusy = false ) 31 | { 32 | RelayCommand cmd; 33 | if( enabledWhenBusy ) 34 | { 35 | cmd = new RelayCommand( action, canExecute: this.CanExecuteWhenBusy ); 36 | } 37 | else 38 | { 39 | cmd = new RelayCommand( action, canExecute: this.CanExecuteWhenNotBusy ); 40 | } 41 | this.busyCommands.Add( cmd ); 42 | return cmd; 43 | } 44 | 45 | private readonly List busyCommands = new List(); 46 | 47 | private Boolean isBusy; 48 | public Boolean IsBusy 49 | { 50 | get { return this.isBusy; } 51 | set 52 | { 53 | if( this.Set( nameof(this.IsBusy), ref this.isBusy, value ) ) 54 | { 55 | this.RaisePropertyChanged( nameof(this.IsNotBusy) ); 56 | foreach( RelayCommand cmd in this.busyCommands ) 57 | { 58 | cmd.RaiseCanExecuteChanged(); 59 | } 60 | } 61 | } 62 | } 63 | public Boolean IsNotBusy => !this.IsBusy; 64 | 65 | protected Boolean CanExecuteWhenNotBusy() 66 | { 67 | //System.Diagnostics.Debug.WriteLine( nameof(this.CanExecuteWhenNotBusy) + " == " + ( !this.IsBusy ) ); 68 | return !this.IsBusy; 69 | } 70 | 71 | protected Boolean CanExecuteWhenBusy() 72 | { 73 | //System.Diagnostics.Debug.WriteLine( nameof(this.CanExecuteWhenBusy) + " == " + ( this.IsBusy ) ); 74 | return this.IsBusy; 75 | } 76 | 77 | protected static ObservableCollection> CreateOptions() 78 | where TEnum : System.Enum /* make sure you're using the C# 7.3 compiler for this: https://github.com/dotnet/csharplang/issues/104 */ 79 | { 80 | //TEnum[] values = (TEnum[])Enum.GetValues( typeof(TEnum) ); 81 | 82 | Type type = typeof(TEnum); 83 | var options = type 84 | .GetFields( BindingFlags.Static | BindingFlags.Public ) 85 | .Select( fi => { 86 | String description = fi.GetCustomAttribute()?.Description; 87 | TEnum value = (TEnum)fi.GetValue( null ); 88 | 89 | return new ValueOption( value, description ?? value.ToString() ); 90 | } ); 91 | 92 | ObservableCollection> ret = new ObservableCollection>(); 93 | foreach( ValueOption opt in options ) ret.Add( opt ); 94 | 95 | return ret; 96 | } 97 | } 98 | 99 | public class ValueOption 100 | { 101 | public ValueOption( T value, String text ) 102 | { 103 | this.Value = value; 104 | this.Text = text; 105 | } 106 | 107 | public T Value { get; } 108 | public String Text { get; } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/DesignTeslaTagService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using System.Windows.Threading; 8 | 9 | namespace TeslaTags.Gui 10 | { 11 | public class DesignTeslaTagService : ITeslaTagsService 12 | { 13 | private String rootDirectory; 14 | 15 | private readonly DispatcherTimer timer; 16 | 17 | private static readonly List _directories = new List() 18 | { 19 | @"Foobar", 20 | @"Foobar\Artist1", 21 | @"Foobar\Artist1\Album", 22 | @"Foobar\ArtistB\Singles", 23 | @"Foobar\Foo", 24 | }; 25 | 26 | private List directories; 27 | 28 | public DesignTeslaTagService() 29 | { 30 | this.timer = new DispatcherTimer(); 31 | this.timer.Interval = new TimeSpan( hours: 0, minutes: 0, seconds: 1 ); 32 | this.timer.Tick += this.Timer_Tick; 33 | } 34 | 35 | private Int32 processState = 0; // 0 = Start, 1 = Getting directories, 2 = Got directories/Processing files, 3 = Done. 36 | private DateTime stateStart = DateTime.MinValue; 37 | private Int32 directoryIdx = 0; 38 | 39 | private TaskCompletionSource tcs; 40 | //private ITeslaTagEventsListener listener; 41 | private CancellationToken ct; 42 | 43 | private IProgress> directoriesProgress; 44 | private IProgress directoryProgress; 45 | 46 | public Task StartRetaggingAsync(RetaggingOptions options, IProgress> directoriesProgress, IProgress directoryProgress, CancellationToken ct) 47 | { 48 | if( this.tcs != null ) throw new InvalidOperationException("Already processing."); // This is fine for a DesignMode class. 49 | 50 | this.directoriesProgress = directoriesProgress; 51 | this.directoryProgress = directoryProgress; 52 | 53 | this.tcs = new TaskCompletionSource(); 54 | this.ct = ct; 55 | 56 | this.rootDirectory = options.MusicRootDirectory; 57 | this.directories = _directories 58 | .Select( p => Path.Combine( this.rootDirectory, p ) ) 59 | .ToList(); 60 | 61 | this.timer.Start(); 62 | 63 | return this.tcs.Task; 64 | } 65 | 66 | private void Timer_Tick(Object sender, EventArgs e) 67 | { 68 | if( this.ct.IsCancellationRequested ) 69 | { 70 | //this.ct.ThrowIfCancellationRequested(); 71 | 72 | this.processState = 3; 73 | this.timer.Stop(); 74 | 75 | this.tcs.SetCanceled(); 76 | return; 77 | } 78 | 79 | if( this.processState == 0 ) 80 | { 81 | this.processState = 1; 82 | this.stateStart = DateTime.UtcNow; 83 | } 84 | else if( this.processState == 1 ) 85 | { 86 | TimeSpan time = DateTime.UtcNow - this.stateStart; 87 | if( time.TotalSeconds > 2 ) 88 | { 89 | this.processState = 2; 90 | this.directoriesProgress.Report( this.directories ); 91 | } 92 | } 93 | else if( this.processState == 2 ) 94 | { 95 | if( this.directoryIdx < this.directories.Count ) 96 | { 97 | String directory = Path.Combine( this.rootDirectory, this.directories[this.directoryIdx] ); 98 | 99 | Random rng = new Random(); 100 | 101 | FolderType randomType = (FolderType)rng.Next( 0, 6 ); 102 | Int32 totalCount = rng.Next( 0, 30 ); 103 | Int32 modifiedCount = rng.Next( 0, totalCount + 1 ); 104 | 105 | List messages = new List(); 106 | if( rng.Next( 0, 2 ) == 1 ) messages.Add( new Message( MessageSeverity.Warning, directory, directory, "A directory warning" ) ); 107 | for( Int32 i = 0; i < totalCount; i++ ) 108 | { 109 | String fileName = Path.Combine( directory, "File_" + i + ".mp3" ); 110 | if( rng.Next(0, 4) == 3 ) messages.Add( new Message( MessageSeverity.Warning, directory, fileName, "Some file warning" ) ); 111 | if( rng.Next(0, 8) == 3 ) messages.Add( new Message( MessageSeverity.Warning, directory, fileName, "Some file error" ) ); 112 | } 113 | 114 | this.directoryProgress.Report( new DirectoryResult( directory, randomType, totalCount, modifiedCount, modifiedCount, messages ) ); 115 | 116 | this.directoryIdx++; 117 | } 118 | else 119 | { 120 | this.processState = 3; 121 | } 122 | } 123 | else if( this.processState == 3 ) 124 | { 125 | // Completed successfully: 126 | this.timer.Stop(); 127 | this.tcs.SetResult( null ); 128 | } 129 | } 130 | 131 | public Task> RemoveApeTagsAsync(String directoryPath, HashSet fileExtensionsToLoad) 132 | { 133 | List messages = new List(); 134 | messages.Add( new Message( MessageSeverity.Warning, @"C:\Music", @"C:\Music\Foo.mp3", "Nothing happened. This is a design-mode class." ) ); 135 | 136 | return Task.FromResult( messages ); 137 | } 138 | 139 | public Task> SetAlbumArtAsync(String directoryPath, HashSet fileExtensionsToLoad, String imageFileName, AlbumArtSetMode mode) 140 | { 141 | List messages = new List(); 142 | messages.Add( new Message( MessageSeverity.Warning, @"C:\Music", @"C:\Music\Foo.mp3", "Nothing happened. This is a design-mode class." ) ); 143 | 144 | return Task.FromResult( messages ); 145 | } 146 | 147 | public Task> SetTrackNumbersFromFileNamesAsync(String directoryPath, HashSet fileExtensionsToLoad, Int32 offset, Int32? discNumber) 148 | { 149 | List messages = new List(); 150 | messages.Add( new Message( MessageSeverity.Warning, @"C:\Music", @"C:\Music\Foo.mp3", "Nothing happened. This is a design-mode class." ) ); 151 | 152 | return Task.FromResult( messages ); 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/DirectoryViewModel.Properties.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.ObjectModel; 3 | 4 | using GalaSoft.MvvmLight.CommandWpf; 5 | 6 | namespace TeslaTags.Gui 7 | { 8 | public partial class DirectoryViewModel 9 | { 10 | // Commands: 11 | 12 | public RelayCommand OpenFolderCommand { get; } 13 | public RelayCommand ApplyAlbumArtCommand { get; } 14 | public RelayCommand RemoveApeTagsCommand { get; } 15 | public RelayCommand SetTrackNumbersCommand { get; } 16 | 17 | #region Messages 18 | 19 | private Int32? filesModifiedProposed; 20 | public Int32? FilesModifiedProposed 21 | { 22 | get { return this.filesModifiedProposed; } 23 | set { this.Set( nameof(this.FilesModifiedProposed), ref this.filesModifiedProposed, value ); } 24 | } 25 | 26 | private Int32? filesModifiedActual; 27 | public Int32? FilesModifiedActual 28 | { 29 | get { return this.filesModifiedActual; } 30 | set { this.Set( nameof(this.FilesModifiedActual), ref this.filesModifiedActual, value ); } 31 | } 32 | 33 | private Int32? totalFiles; 34 | public Int32? TotalFiles 35 | { 36 | get { return this.totalFiles; } 37 | set { this.Set( nameof(this.TotalFiles), ref this.totalFiles, value ); } 38 | } 39 | 40 | private FolderType? folderType; 41 | public FolderType? FolderType 42 | { 43 | get { return this.folderType; } 44 | set { this.Set( nameof(this.FolderType), ref this.folderType, value ); } 45 | } 46 | 47 | private String lastOperationSummary; 48 | public String LastOperationSummary 49 | { 50 | get { return this.lastOperationSummary ?? String.Empty; } 51 | set { this.Set( nameof(this.LastOperationSummary), ref this.lastOperationSummary, value ); } 52 | } 53 | 54 | #endregion 55 | 56 | #region Apply Album Art: 57 | 58 | private String selectedImageFileName; 59 | public String SelectedImageFileName 60 | { 61 | get { return this.selectedImageFileName; } 62 | set { this.Set( nameof(this.SelectedImageFileName), ref this.selectedImageFileName, value ); } 63 | } 64 | 65 | private String albumArtMessage; 66 | public String AlbumArtMessage 67 | { 68 | get { return this.albumArtMessage; } 69 | set { this.Set( nameof(this.AlbumArtMessage), ref this.albumArtMessage, value ); } 70 | } 71 | 72 | private Boolean replaceAllAlbumArt; 73 | public Boolean ReplaceAllAlbumArt 74 | { 75 | get { return this.replaceAllAlbumArt; } 76 | set { this.Set( nameof(this.ReplaceAllAlbumArt), ref this.replaceAllAlbumArt, value ); } 77 | } 78 | 79 | /// Contains file-names, not file-paths. 80 | public ObservableCollection ImagesInFolder { get; } = new ObservableCollection(); 81 | 82 | #endregion 83 | 84 | #region Track numbers 85 | 86 | private Int32 trackNumberOffset; 87 | public Int32 TrackNumberOffset 88 | { 89 | get { return this.trackNumberOffset; } 90 | set { this.Set( nameof(this.TrackNumberOffset), ref this.trackNumberOffset, value ); } 91 | } 92 | 93 | private Int32? discNumber; 94 | public Int32? DiscNumber 95 | { 96 | get { return this.discNumber; } 97 | set { this.Set( nameof(this.DiscNumber), ref this.discNumber, value ); } 98 | } 99 | 100 | #endregion 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/DirectoryViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Collections.Specialized; 5 | using System.IO; 6 | using System.Linq; 7 | 8 | using GalaSoft.MvvmLight; 9 | using GalaSoft.MvvmLight.CommandWpf; 10 | 11 | namespace TeslaTags.Gui 12 | { 13 | public partial class DirectoryViewModel : BaseViewModel 14 | { 15 | private readonly ITeslaTagsService teslaTagService; 16 | private readonly ILiveConfigurationService liveConfiguration; 17 | 18 | public DirectoryViewModel( ITeslaTagsService teslaTagService, ILiveConfigurationService liveConfiguration, String directoryPath, String prefix, IEnumerable imagesInFolder ) 19 | { 20 | this.teslaTagService = teslaTagService ?? throw new ArgumentNullException(nameof(teslaTagService)); 21 | this.liveConfiguration = liveConfiguration ?? throw new ArgumentNullException(nameof(liveConfiguration)); 22 | 23 | if( imagesInFolder == null ) throw new ArgumentNullException(nameof(imagesInFolder)); 24 | 25 | // 26 | 27 | this.FullDirectoryPath = directoryPath ?? throw new ArgumentNullException(nameof(directoryPath)); 28 | this.DisplayDirectoryPath = directoryPath.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ? directoryPath.Substring( prefix.Length ) : directoryPath; 29 | 30 | this.Messages.CollectionChanged += this.Messages_CollectionChanged; 31 | 32 | this.OpenFolderCommand = new RelayCommand( this.OpenFolder ); 33 | this.ApplyAlbumArtCommand = this.CreateBusyCommand( this.ApplyAlbumArt ); 34 | this.RemoveApeTagsCommand = this.CreateBusyCommand( this.RemoveApeTags ); 35 | this.SetTrackNumbersCommand = this.CreateBusyCommand( this.SetTrackNumbers ); 36 | 37 | // 38 | 39 | this.ImagesInFolder.AddRange( imagesInFolder.Select( fi => fi.Name ) ); 40 | } 41 | 42 | #region Messages 43 | 44 | private void Messages_CollectionChanged(Object sender, NotifyCollectionChangedEventArgs e) 45 | { 46 | this.RaisePropertyChanged( nameof(this.InfoCount) ); 47 | this.RaisePropertyChanged( nameof(this.WarnCount) ); 48 | this.RaisePropertyChanged( nameof(this.ErrorCount) ); 49 | 50 | this.RaisePropertyChanged( nameof(this.ShowWarnColor) ); 51 | this.RaisePropertyChanged( nameof(this.ShowErrorColor) ); 52 | } 53 | 54 | public ObservableCollection Messages { get; } = new ObservableCollection(); 55 | 56 | public Int32 InfoCount => this.Messages.Count( m => m.Severity == MessageSeverity.Info || m.Severity == MessageSeverity.FileModification ); 57 | public Int32 WarnCount => this.Messages.Count( m => m.Severity == MessageSeverity.Warning ); 58 | public Int32 ErrorCount => this.Messages.Count( m => m.Severity == MessageSeverity.Error ); 59 | 60 | public Boolean ShowWarnColor => this.Messages.Any( m => m.Severity == MessageSeverity.Warning ); 61 | public Boolean ShowErrorColor => this.Messages.Any( m => m.Severity == MessageSeverity.Error ); 62 | 63 | #endregion 64 | 65 | public String FullDirectoryPath { get; } 66 | public String DisplayDirectoryPath { get; } 67 | 68 | public void OpenFolder() 69 | { 70 | using( System.Diagnostics.Process.Start( this.FullDirectoryPath ) ) { } // `Process.Start()` returns null if it's handled by an existing process, e.g. explorer.exe 71 | } 72 | 73 | #region Apply Album Art 74 | 75 | internal static HashSet AlbumArtFileExtensions { get; } = FileSystemPredicate.CreateFileExtensionHashSet( FileSystemPredicate.DefaultAlbumartImageFileExtensions ); 76 | 77 | public async void ApplyAlbumArt() 78 | { 79 | if( String.IsNullOrWhiteSpace( this.SelectedImageFileName ) ) 80 | { 81 | this.AlbumArtMessage = "No file specified."; 82 | return; 83 | } 84 | 85 | String fileName = Path.IsPathRooted( this.SelectedImageFileName ) ? this.SelectedImageFileName : Path.Combine( this.FullDirectoryPath, this.SelectedImageFileName ); 86 | 87 | if( !File.Exists( fileName ) ) 88 | { 89 | this.AlbumArtMessage = "File \"" + this.SelectedImageFileName + "\" does not exist."; 90 | return; 91 | } 92 | 93 | this.IsBusy = true; 94 | 95 | FileSystemPredicate fsp = this.liveConfiguration.CreateFileSystemPredicate(); 96 | 97 | List messages = await this.teslaTagService.SetAlbumArtAsync( this.FullDirectoryPath, fsp.FileExtensionsToLoad, this.SelectedImageFileName, this.ReplaceAllAlbumArt ? AlbumArtSetMode.Replace : AlbumArtSetMode.AddIfMissing ); 98 | this.Messages.AddRange( messages ); 99 | 100 | String summary = "Set album art in {0:N0} files.".FormatCurrent( messages.GetModifiedFileCount() ); 101 | this.LastOperationSummary = summary; 102 | 103 | this.IsBusy = false; 104 | } 105 | 106 | #endregion 107 | 108 | public async void RemoveApeTags() 109 | { 110 | this.IsBusy = true; 111 | 112 | FileSystemPredicate fsp = this.liveConfiguration.CreateFileSystemPredicate(); 113 | 114 | List messages = await this.teslaTagService.RemoveApeTagsAsync( this.FullDirectoryPath, fsp.FileExtensionsToLoad ); 115 | this.Messages.AddRange( messages ); 116 | 117 | String summary = "Removed APE tags from {0:N0} files.".FormatCurrent( messages.GetModifiedFileCount() ); 118 | this.LastOperationSummary = summary; 119 | 120 | this.IsBusy = false; 121 | } 122 | 123 | #region Track numbers 124 | 125 | public async void SetTrackNumbers() 126 | { 127 | this.IsBusy = true; 128 | 129 | FileSystemPredicate fsp = this.liveConfiguration.CreateFileSystemPredicate(); 130 | 131 | List messages = await this.teslaTagService.SetTrackNumbersFromFileNamesAsync( this.FullDirectoryPath, fsp.FileExtensionsToLoad, this.TrackNumberOffset, this.DiscNumber ); 132 | this.Messages.AddRange( messages ); 133 | 134 | String summary = "Set track numbers in {0:N0} files.".FormatCurrent( messages.GetModifiedFileCount() ); 135 | this.LastOperationSummary = summary; 136 | 137 | this.IsBusy = false; 138 | } 139 | 140 | #endregion 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/GenreRulesViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Windows.Data; 4 | 5 | namespace TeslaTags.Gui 6 | { 7 | public class GenreRulesViewModel : BaseViewModel 8 | { 9 | /// Creates a new object. 10 | public GenreRules GetRules() 11 | { 12 | if( this.OverrideGenreTagEnabled ) 13 | { 14 | return new GenreRules() 15 | { 16 | AssortedFilesAction = this.AssortedFilesAction, 17 | ArtistAlbumWithGuestArtistsAction = this.ArtistAlbumWithGuestArtistsAction, 18 | ArtistAssortedAction = this.ArtistAssortedAction, 19 | ArtistAlbumAction = this.ArtistAlbumAction, 20 | CompilationAlbumAction = this.CompilationAlbumAction 21 | }; 22 | } 23 | else 24 | { 25 | return new GenreRules() 26 | { 27 | AssortedFilesAction = AssortedFilesGenreAction.Preserve, 28 | ArtistAlbumWithGuestArtistsAction = GenreAction.Preserve, 29 | ArtistAssortedAction = GenreAction.Preserve, 30 | ArtistAlbumAction = GenreAction.Preserve, 31 | CompilationAlbumAction = GenreAction.Preserve 32 | }; 33 | } 34 | } 35 | 36 | public void LoadFrom( GenreRules newRules ) 37 | { 38 | if( newRules == null ) throw new ArgumentNullException(nameof(newRules)); 39 | 40 | this.OverrideGenreTagEnabled = !newRules.AlwaysNoop; 41 | 42 | this.AssortedFilesAction = newRules.AssortedFilesAction; 43 | this.ArtistAlbumWithGuestArtistsAction = newRules.ArtistAlbumWithGuestArtistsAction; 44 | this.ArtistAssortedAction = newRules.ArtistAssortedAction; 45 | this.ArtistAlbumAction = newRules.ArtistAlbumAction; 46 | this.CompilationAlbumAction = newRules.CompilationAlbumAction; 47 | } 48 | 49 | private Boolean overrideGenreTagEnabled; 50 | public Boolean OverrideGenreTagEnabled 51 | { 52 | get { return this.overrideGenreTagEnabled; } 53 | set { this.Set( nameof(this.OverrideGenreTagEnabled), ref this.overrideGenreTagEnabled, value ); } 54 | } 55 | 56 | // https://stackoverflow.com/questions/397556/how-to-bind-radiobuttons-to-an-enum 57 | 58 | private AssortedFilesGenreAction assortedFilesAction; 59 | public AssortedFilesGenreAction AssortedFilesAction 60 | { 61 | get { return this.assortedFilesAction; } 62 | set { this.Set( nameof(this.AssortedFilesAction), ref this.assortedFilesAction, value ); } 63 | } 64 | 65 | private GenreAction artistAlbumWithGuestArtistsAction; 66 | public GenreAction ArtistAlbumWithGuestArtistsAction 67 | { 68 | get { return this.artistAlbumWithGuestArtistsAction; } 69 | set { this.Set( nameof(this.ArtistAlbumWithGuestArtistsAction), ref this.artistAlbumWithGuestArtistsAction, value ); } 70 | } 71 | 72 | private GenreAction artistAssortedAction; 73 | public GenreAction ArtistAssortedAction 74 | { 75 | get { return this.artistAssortedAction; } 76 | set { this.Set( nameof(this.ArtistAssortedAction), ref this.artistAssortedAction, value ); } 77 | } 78 | 79 | private GenreAction artistAlbumAction; 80 | public GenreAction ArtistAlbumAction 81 | { 82 | get { return this.artistAlbumAction; } 83 | set { this.Set( nameof(this.ArtistAlbumAction), ref this.artistAlbumAction, value ); } 84 | } 85 | 86 | private GenreAction compilationAlbumAction; 87 | public GenreAction CompilationAlbumAction 88 | { 89 | get { return this.compilationAlbumAction; } 90 | set { this.Set( nameof(this.CompilationAlbumAction), ref this.compilationAlbumAction, value ); } 91 | } 92 | } 93 | 94 | public class ComparisonConverter : IValueConverter 95 | { 96 | public Object Convert( Object value, Type targetType, Object parameter, CultureInfo culture ) 97 | { 98 | return value?.Equals( parameter ); 99 | } 100 | 101 | public Object ConvertBack( Object value, Type targetType, Object parameter, CultureInfo culture ) 102 | { 103 | return value?.Equals( true ) == true ? parameter : Binding.DoNothing; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/MainViewModel.Properties.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Globalization; 5 | 6 | using GalaSoft.MvvmLight.CommandWpf; 7 | 8 | namespace TeslaTags.Gui 9 | { 10 | public partial class MainViewModel 11 | { 12 | #region Two-way 13 | 14 | private String directoryPath; 15 | public String DirectoryPath 16 | { 17 | get { return this.directoryPath; } 18 | set 19 | { 20 | if( this.Set( nameof(this.DirectoryPath), ref this.directoryPath, value ) ) 21 | { 22 | this.StartCommand.RaiseCanExecuteChanged(); 23 | } 24 | } 25 | } 26 | 27 | private Boolean onlyValidate; 28 | public Boolean OnlyValidate 29 | { 30 | get { return this.onlyValidate; } 31 | set { this.Set( nameof(this.OnlyValidate), ref this.onlyValidate, value ); } 32 | } 33 | 34 | private String excludeLines; 35 | public String ExcludeLines 36 | { 37 | get { return this.excludeLines ?? String.Empty; } 38 | set { this.Set( nameof(this.ExcludeLines), ref this.excludeLines, value ); } 39 | } 40 | 41 | private String fileExtensionsToLoad; 42 | public String FileExtensionsToLoad 43 | { 44 | get { return this.fileExtensionsToLoad ?? String.Empty; } 45 | set { this.Set( nameof(this.FileExtensionsToLoad), ref this.fileExtensionsToLoad, value ); } 46 | } 47 | 48 | private Boolean restoreFiles; 49 | public Boolean RestoreFiles 50 | { 51 | get { return this.restoreFiles; } 52 | set { this.Set( nameof(this.RestoreFiles), ref this.restoreFiles, value ); } 53 | } 54 | 55 | private Boolean hideBoringDirectories; 56 | public Boolean HideBoringDirectories 57 | { 58 | get { return this.hideBoringDirectories; } 59 | set { this.Set( nameof(this.HideBoringDirectories), ref this.hideBoringDirectories, value ); } 60 | } 61 | 62 | public GenreRulesViewModel GenreRules { get; } = new GenreRulesViewModel(); 63 | 64 | private readonly Dictionary viewModelDict = new Dictionary( StringComparer.OrdinalIgnoreCase ); 65 | 66 | public ObservableCollection DirectoriesProgress { get; } = new ObservableCollection(); 67 | 68 | private DirectoryViewModel selectedDirectory; 69 | public DirectoryViewModel SelectedDirectory 70 | { 71 | get { return this.selectedDirectory; } 72 | set { this.Set( nameof(this.SelectedDirectory), ref this.selectedDirectory, value ); } 73 | } 74 | 75 | #endregion 76 | 77 | #region One-way from ViewModel 78 | 79 | private Single progressPerc; 80 | public Single ProgressPerc 81 | { 82 | get { return this.progressPerc; } 83 | set 84 | { 85 | this.Set( nameof(this.ProgressPerc), ref this.progressPerc, value ); 86 | this.RaisePropertyChanged( nameof(this.WindowTitle) ); 87 | } 88 | } 89 | 90 | private ProgressState progressStatus; 91 | public ProgressState ProgressStatus 92 | { 93 | get { return this.progressStatus; } 94 | set 95 | { 96 | this.Set( nameof(this.ProgressStatus), ref this.progressStatus, value ); 97 | this.RaisePropertyChanged( nameof(this.TaskbarProgressStatus) ); 98 | this.RaisePropertyChanged( nameof(this.WindowTitle) ); 99 | } 100 | } 101 | 102 | public System.Windows.Shell.TaskbarItemProgressState TaskbarProgressStatus 103 | { 104 | get 105 | { 106 | switch( this.ProgressStatus ) 107 | { 108 | case ProgressState.StartingIndeterminate: return System.Windows.Shell.TaskbarItemProgressState.Indeterminate; 109 | case ProgressState.Running : return System.Windows.Shell.TaskbarItemProgressState.Normal; 110 | case ProgressState.Error : return System.Windows.Shell.TaskbarItemProgressState.Error; 111 | case ProgressState.Canceled : return System.Windows.Shell.TaskbarItemProgressState.Paused; 112 | 113 | case ProgressState.Completed: 114 | case ProgressState.NotStarted: 115 | default: 116 | return System.Windows.Shell.TaskbarItemProgressState.None; 117 | } 118 | } 119 | } 120 | 121 | public String WindowTitle 122 | { 123 | get 124 | { 125 | switch( this.ProgressStatus ) 126 | { 127 | case ProgressState.StartingIndeterminate: return "TeslaTags - Starting..."; 128 | case ProgressState.Running : return "TeslaTags - " + this.ProgressPerc.ToString( "P0", CultureInfo.CurrentCulture ) + " complete."; 129 | case ProgressState.Error : return "TeslaTags - Error"; 130 | case ProgressState.Completed : return "TeslaTags - Complete"; 131 | case ProgressState.Canceled: 132 | case ProgressState.NotStarted: 133 | default: 134 | return "TeslaTags"; 135 | } 136 | } 137 | } 138 | 139 | public String Version { get; } 140 | 141 | public String ReadmeLink { get; } 142 | 143 | #endregion 144 | 145 | #region Commands 146 | 147 | public RelayCommand WindowLoadedCommand { get; } 148 | 149 | public RelayCommand WindowClosingCommand { get; } 150 | 151 | public RelayCommand StartCommand { get; } 152 | 153 | public RelayCommand StopCommand { get; } 154 | 155 | #endregion 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/MainViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | using GalaSoft.MvvmLight.CommandWpf; 9 | 10 | namespace TeslaTags.Gui 11 | { 12 | public partial class MainViewModel : BaseViewModel, ILiveConfigurationService 13 | { 14 | private readonly ITeslaTagsService teslaTagsService; 15 | private readonly IConfigurationService configurationService; 16 | private readonly IWindowService windowService; 17 | 18 | private CancellationTokenSource teslaTagsCts; 19 | 20 | public MainViewModel(ITeslaTagsService teslaTagsService, IConfigurationService configurationService, IWindowService windowService) 21 | { 22 | this.teslaTagsService = teslaTagsService; 23 | this.configurationService = configurationService; 24 | this.windowService = windowService; 25 | 26 | this.WindowLoadedCommand = new RelayCommand( this.WindowLoaded ); 27 | this.WindowClosingCommand = new RelayCommand( this.WindowClosing ); 28 | this.StartCommand = this.CreateBusyCommand( this.Start ); // , additionalCanExecute: this.DirectoryPathIsValid ); // weird, why doesn't this work? my `DirectoryPathIsValid` function isn't invoked after editing the textbox. 29 | this.StopCommand = this.CreateBusyCommand( this.Stop, enabledWhenBusy: true ); 30 | 31 | /////////// 32 | 33 | this.directoryPath = Environment.GetFolderPath(Environment.SpecialFolder.MyMusic); 34 | this.onlyValidate = true; 35 | 36 | this.Version = typeof(MainViewModel).Assembly.GetName().Version.ToString() + " (Release 6)"; 37 | this.ReadmeLink = @"https://github.com/Jehoel/TeslaTags/blob/release-4/README.md"; 38 | 39 | ////////// 40 | 41 | if( this.IsInDesignMode ) 42 | { 43 | for( Int32 i = 0; i < 10; i++ ) 44 | { 45 | this.DirectoriesProgress.Add( new DirectoryViewModel( teslaTagsService, liveConfiguration: this, @"C:\TestData\Folder" + i, @"C:\TestData", imagesInFolder: Array.Empty() ) ); 46 | } 47 | this.SelectedDirectory = this.DirectoriesProgress[2]; 48 | 49 | String dir = this.SelectedDirectory.FullDirectoryPath; 50 | this.SelectedDirectory.Messages.Add( new Message( MessageSeverity.Error , dir, Path.Combine( dir, "File.mp3" ), "Message text" ) ); 51 | this.SelectedDirectory.Messages.Add( new Message( MessageSeverity.FileModification, dir, Path.Combine( dir, "File.mp3" ), "Message text" ) ); 52 | this.SelectedDirectory.Messages.Add( new Message( MessageSeverity.Info , dir, Path.Combine( dir, "File.mp3" ), "Message text" ) ); 53 | this.SelectedDirectory.Messages.Add( new Message( MessageSeverity.Warning , dir, Path.Combine( dir, "File.mp3" ), "Message text" ) ); 54 | } 55 | } 56 | 57 | private Boolean DirectoryPathIsValid() 58 | { 59 | return !String.IsNullOrWhiteSpace( this.DirectoryPath ) && Directory.Exists( this.DirectoryPath ); 60 | } 61 | 62 | private void WindowLoaded() 63 | { 64 | this.LoadConfig(); 65 | } 66 | 67 | private void WindowClosing() 68 | { 69 | this.SaveConfig(); 70 | } 71 | 72 | private void LoadConfig() 73 | { 74 | Config config = this.configurationService.Config; 75 | 76 | if( !String.IsNullOrWhiteSpace( config.RootDirectory ) && Directory.Exists( config.RootDirectory ) ) 77 | { 78 | this.DirectoryPath = config.RootDirectory; 79 | } 80 | 81 | this.HideBoringDirectories = config.HideEmptyDirectories; 82 | this.ExcludeLines = String.Join( "\r\n", ( config.ExcludeList ?? Array.Empty() ).OrderBy( s => s ) ); 83 | this.FileExtensionsToLoad = String.Join( "\r\n", ( config.FileExtensions ?? Array.Empty() ).OrderBy( s => s ) ); 84 | 85 | if( config.GenreRules != null ) 86 | { 87 | this.GenreRules.LoadFrom( config.GenreRules ); 88 | } 89 | 90 | var window = this.windowService.GetWindowByDataContext( this ); 91 | if( window != null ) 92 | { 93 | var pos = config.RestoredWindowPosition; // `#pragma warning disable 42` does not suppress the IDE0042 message here. 94 | 95 | if( pos.Width > 0 && pos.Height > 0 ) 96 | { 97 | window.Top = pos.Y; 98 | window.Left = pos.X; 99 | window.Width = pos.Width; 100 | window.Height = pos.Height; 101 | } 102 | 103 | if( config.IsMaximized ) window.WindowState = System.Windows.WindowState.Maximized; 104 | } 105 | } 106 | 107 | private void SaveConfig() 108 | { 109 | Config config = this.configurationService.Config; 110 | 111 | config.RootDirectory = this.DirectoryPath; 112 | config.HideEmptyDirectories = this.HideBoringDirectories; 113 | config.ExcludeList = this.ExcludeLines .Split( new String[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries ) ?? Array.Empty(); 114 | config.FileExtensions = this.FileExtensionsToLoad.Split( new String[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries ) ?? Array.Empty(); 115 | config.GenreRules = this.GenreRules.GetRules(); 116 | 117 | var window = this.windowService.GetWindowByDataContext( this ); 118 | var rb = window.RestoreBounds; 119 | 120 | config.RestoredWindowPosition = ( X: (Int32)rb.Left, Y: (Int32)rb.Top, Width: (Int32)rb.Width, Height: (Int32)rb.Height ); 121 | config.IsMaximized = window.WindowState == System.Windows.WindowState.Maximized; 122 | 123 | ///// 124 | 125 | this.configurationService.SaveConfig( config ); 126 | } 127 | 128 | public FileSystemPredicate CreateFileSystemPredicate() 129 | { 130 | List directoryExclusions = this.ExcludeLines 131 | .Split( new String[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries ) 132 | .Select( line => line.Trim( ' ', '\\', '/' ) ) 133 | .ToList(); 134 | 135 | IDirectoryPredicate directoryFilterPredicate = ( directoryExclusions.Count == 0 ) ? (IDirectoryPredicate)new EmptyDirectoryPredicate() : new ExactPathComponentMatchPredicate( directoryExclusions ); 136 | 137 | IEnumerable extensions = this.FileExtensionsToLoad 138 | .Split( new String[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries ); // `FileSystemPredicate` sanitizes the extensions and is case-insensitive too. 139 | 140 | FileSystemPredicate fsp = new FileSystemPredicate( directoryFilterPredicate, extensions ); 141 | return fsp; 142 | } 143 | 144 | private async void Start() 145 | { 146 | if( !this.DirectoryPathIsValid() ) 147 | { 148 | this.windowService.ShowMessageBoxErrorDialog( this, title: "TeslaTags", message: "The music root directory is not specified, or does not exist." ); 149 | return; 150 | } 151 | 152 | this.IsBusy = true; 153 | this.ProgressStatus = ProgressState.StartingIndeterminate; 154 | try 155 | { 156 | // Save config (so we can restore, in case it crashes during processing): 157 | this.SaveConfig(); 158 | 159 | FileSystemPredicate fsp = this.CreateFileSystemPredicate(); 160 | 161 | RetaggingOptions opts = new RetaggingOptions( this.DirectoryPath, this.OnlyValidate, this.RestoreFiles, fsp, this.GenreRules.GetRules() ); 162 | 163 | Single total = 0; 164 | Single progress = 0; 165 | 166 | Progress> directoriesReceiver = new Progress>( directories => 167 | { 168 | this.viewModelDict.Clear(); 169 | this.DirectoriesProgress.Clear(); 170 | total = directories.Count; 171 | 172 | foreach( String directoryPath in directories ) 173 | { 174 | if( directoryPath == null ) continue; 175 | 176 | // HACK: Get it so this list is loaded on-demand instead of eagerly: 177 | DirectoryInfo dir = new DirectoryInfo( directoryPath ); 178 | 179 | List imageFiles = dir.GetFiles() 180 | .Where( fi => DirectoryViewModel.AlbumArtFileExtensions.Contains( fi.Extension ) ) 181 | .OrderBy( fi => fi.FullName ) 182 | .ToList(); 183 | 184 | DirectoryViewModel dirVM = new DirectoryViewModel( this.teslaTagsService, liveConfiguration: this, directoryPath, prefix: this.DirectoryPath, imageFiles ); 185 | this.viewModelDict.Add( directoryPath, dirVM ); 186 | this.DirectoriesProgress.Add( dirVM ); 187 | } 188 | 189 | this.ProgressStatus = ProgressState.Running; 190 | } ); 191 | 192 | Progress directoryReceiver = new Progress( result => 193 | { 194 | DirectoryViewModel dirVM; 195 | if( !this.viewModelDict.TryGetValue( result.DirectoryPath, out dirVM ) ) 196 | { 197 | throw new InvalidOperationException( "Event raised in previously unreported directory: " + result.DirectoryPath ); 198 | } 199 | 200 | dirVM.FilesModifiedActual = result.ActualModifiedFiles; 201 | dirVM.FilesModifiedProposed = result.ProposedModifiedFiles; 202 | dirVM.FolderType = result.FolderType; 203 | dirVM.TotalFiles = result.TotalFiles; 204 | 205 | foreach( Message message in result.Messages ) dirVM.Messages.Add( message ); 206 | 207 | progress++; 208 | 209 | this.ProgressPerc = progress / total; 210 | } ); 211 | 212 | // Start for real: 213 | CancellationTokenSource cts = this.teslaTagsCts = new CancellationTokenSource(); 214 | 215 | Task task = this.teslaTagsService.StartRetaggingAsync( opts, directoriesReceiver, directoryReceiver, cts.Token ); 216 | await task; 217 | 218 | if( task.IsCanceled ) 219 | { 220 | this.ProgressStatus = ProgressState.Canceled; 221 | } 222 | else 223 | { 224 | this.ProgressStatus = ProgressState.Completed; 225 | } 226 | } 227 | catch( OperationCanceledException ) // includes TaskCanceledException 228 | { 229 | this.ProgressStatus = ProgressState.Canceled; 230 | } 231 | catch 232 | { 233 | this.ProgressStatus = ProgressState.Error; 234 | throw; 235 | } 236 | finally 237 | { 238 | this.IsBusy = false; 239 | this.teslaTagsCts = null; 240 | } 241 | } 242 | 243 | private void Stop() 244 | { 245 | if( this.teslaTagsCts == null ) throw new InvalidOperationException( "CancellationTokenSource is null." ); 246 | 247 | this.teslaTagsCts.Cancel(); 248 | } 249 | } 250 | 251 | public enum ProgressState 252 | { 253 | NotStarted, 254 | StartingIndeterminate, 255 | Running, 256 | Completed, 257 | Canceled, 258 | Error 259 | } 260 | } -------------------------------------------------------------------------------- /TeslaTags.Gui/ViewModel/ViewModelLocator.cs: -------------------------------------------------------------------------------- 1 | /* 2 | In App.xaml: 3 | 4 | 5 | 6 | 7 | In the View: 8 | DataContext="{Binding Source={StaticResource Locator}, Path=ViewModelName}" 9 | 10 | You can also use Blend to do all this with the tool's support. 11 | See http://www.galasoft.ch/mvvm 12 | */ 13 | 14 | using CommonServiceLocator; 15 | 16 | using GalaSoft.MvvmLight; 17 | using GalaSoft.MvvmLight.Ioc; 18 | 19 | namespace TeslaTags.Gui 20 | { 21 | public class ViewModelLocator 22 | { 23 | public ViewModelLocator() 24 | { 25 | } 26 | 27 | public MainViewModel MainWindow 28 | { 29 | get 30 | { 31 | return ServiceLocator.Current.GetInstance(); 32 | } 33 | } 34 | 35 | public static void Cleanup() 36 | { 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /TeslaTags.Gui/Views/DataGridBehavior.AutoScroll.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.Windows; 5 | using System.Windows.Controls; 6 | 7 | namespace TeslaTags.Gui 8 | { 9 | /// From https://stackoverflow.com/questions/1027051/how-to-autoscroll-on-wpf-datagrid 10 | public static class DataGridBehavior 11 | { 12 | public static readonly DependencyProperty AutoscrollProperty = DependencyProperty.RegisterAttached( name: "Autoscroll", propertyType: typeof(Boolean), ownerType: typeof(DataGridBehavior), new PropertyMetadata( defaultValue: default(Boolean), propertyChangedCallback: AutoscrollChangedCallback ) ); 13 | 14 | private static readonly Dictionary _handlersDict = new Dictionary(); 15 | 16 | private static void AutoscrollChangedCallback( DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args ) 17 | { 18 | DataGrid dataGrid = dependencyObject as DataGrid; 19 | if( dataGrid == null ) 20 | { 21 | throw new InvalidOperationException( "Dependency object is not DataGrid." ); 22 | } 23 | 24 | if( (Boolean)args.NewValue ) 25 | { 26 | Subscribe( dataGrid ); 27 | } 28 | else 29 | { 30 | Unsubscribe( dataGrid ); 31 | } 32 | } 33 | 34 | private static void Subscribe( DataGrid dataGrid ) 35 | { 36 | if( !_handlersDict.ContainsKey( dataGrid ) ) 37 | { 38 | NotifyCollectionChangedEventHandler handler = new NotifyCollectionChangedEventHandler( ( Object sender, NotifyCollectionChangedEventArgs e ) => ScrollToEnd( dataGrid ) ); // `sender` is a Collection, not the DataGrid. 39 | 40 | ( (INotifyCollectionChanged)dataGrid.Items ).CollectionChanged += handler; 41 | 42 | _handlersDict.Add( dataGrid, handler ); 43 | 44 | dataGrid.Unloaded += DataGridOnUnloaded; 45 | dataGrid.Loaded += DataGridOnLoaded; 46 | 47 | ScrollToEnd( dataGrid ); 48 | } 49 | } 50 | 51 | private static void Unsubscribe( DataGrid dataGrid ) 52 | { 53 | if( _handlersDict.TryGetValue( dataGrid, out NotifyCollectionChangedEventHandler handler ) ) 54 | { 55 | ( (INotifyCollectionChanged)dataGrid.Items ).CollectionChanged -= handler; 56 | 57 | _handlersDict.Remove( dataGrid ); 58 | 59 | dataGrid.Unloaded -= DataGridOnUnloaded; 60 | dataGrid.Loaded -= DataGridOnLoaded; 61 | } 62 | } 63 | 64 | private static void DataGridOnLoaded( Object sender, RoutedEventArgs routedEventArgs ) 65 | { 66 | DataGrid dataGrid = (DataGrid)sender; 67 | if( GetAutoscroll( dataGrid ) ) 68 | { 69 | Subscribe( dataGrid ); 70 | } 71 | } 72 | 73 | private static void DataGridOnUnloaded( Object sender, RoutedEventArgs routedEventArgs ) 74 | { 75 | DataGrid dataGrid = (DataGrid)sender; 76 | if( GetAutoscroll( dataGrid ) ) 77 | { 78 | Unsubscribe( dataGrid ); 79 | } 80 | } 81 | 82 | private static void ScrollToEnd( DataGrid datagrid ) 83 | { 84 | if( datagrid.Items.Count > 0 ) 85 | { 86 | datagrid.ScrollIntoView( datagrid.Items[datagrid.Items.Count - 1] ); 87 | } 88 | } 89 | 90 | public static void SetAutoscroll( DependencyObject element, Boolean value ) 91 | { 92 | element.SetValue( AutoscrollProperty, value ); 93 | } 94 | 95 | public static Boolean GetAutoscroll( DependencyObject element ) 96 | { 97 | return (Boolean)element.GetValue( AutoscrollProperty ); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /TeslaTags.Gui/app.manifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 52 | 59 | 60 | 61 | 62 | 63 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /TeslaTags.Gui/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TeslaTags.QuickFix/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TeslaTags.QuickFix/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.RegularExpressions; 7 | 8 | using TagLib; 9 | 10 | namespace TeslaTags.QuickFix 11 | { 12 | public static class Program 13 | { 14 | public static void Main(String[] args) 15 | { 16 | if( args.Length < 2 ) 17 | { 18 | Console.WriteLine( "Usage: TeslaTags.QuickFix.exe [] [all|missing|prompt]" ); 19 | 20 | Console.WriteLine(); 21 | Console.WriteLine( "Example: TeslaTags.QuickFix.exe . auto-trackNumbers" ); 22 | Console.WriteLine( "\tSets the track numbers based on the filename."); 23 | 24 | Console.WriteLine(); 25 | Console.WriteLine( "Example: TeslaTags.QuickFix.exe . set-albumArt Image.png" ); 26 | Console.WriteLine( "\tSets the album-art to Image.png."); 27 | 28 | Console.WriteLine(); 29 | Console.WriteLine( "Example: TeslaTags.QuickFix.exe . remove-ape" ); 30 | Console.WriteLine( "\tRemoves APE tags."); 31 | 32 | return; 33 | } 34 | 35 | String directoryPath = args[0]; 36 | if( directoryPath == "." ) directoryPath = Environment.CurrentDirectory; 37 | 38 | String operation = args[1]; 39 | String value = args.Length >= 3 ? args[2] : null; 40 | 41 | Mode mode = Mode.Prompt; 42 | if( args.Length >= 4 ) 43 | { 44 | String modeStr = args[3].ToUpperInvariant(); 45 | if( modeStr == "ALL" ) mode = Mode.All; 46 | else if( modeStr == "MISSING" ) mode = Mode.Missing; 47 | else if( modeStr == "PROMPT" ) mode = Mode.Prompt; 48 | else 49 | { 50 | Console.WriteLine("Unrecognised argument: \"" + args[3] + "\"."); 51 | return; 52 | } 53 | } 54 | 55 | //////////////////////////// 56 | 57 | MainInner( directoryPath, operation, value, mode ); 58 | 59 | if( mode == Mode.Prompt ) 60 | { 61 | Console.WriteLine("Completed. Press [Return] to exit."); 62 | Console.ReadLine(); 63 | } 64 | } 65 | 66 | private static void MainInner( String directoryPath, String operation, String value, Mode mode ) 67 | { 68 | FileSystemPredicate fsp = new FileSystemPredicate( directoryPredicate: new EmptyDirectoryPredicate(), caseInsensitiveFileExtensions: FileSystemPredicate.DefaultAudioFileExtensions ); 69 | 70 | List messages = new List(); 71 | List files = TeslaTagFolderProcessor.LoadFiles( directoryPath, fsp.FileExtensionsToLoad, messages ); 72 | try 73 | { 74 | foreach( Message msg in messages ) 75 | { 76 | Console.WriteLine( msg.ToString() ); 77 | } 78 | 79 | switch( operation.ToUpperInvariant() ) 80 | { 81 | case "AUTO-TRACKNUMBERS": 82 | 83 | AutoTrackNumbers( files, mode ); 84 | break; 85 | 86 | case "SET-ALBUMART": 87 | 88 | SetAlbumArt( directoryPath, files, value, mode ); 89 | break; 90 | 91 | case "REMOVE-APE": 92 | 93 | RemoveApe( files ); 94 | break;; 95 | } 96 | } 97 | finally 98 | { 99 | foreach( LoadedFile file in files ) 100 | { 101 | if( file.IsModified ) 102 | { 103 | file.Save( messages ); 104 | } 105 | file.Dispose(); 106 | } 107 | } 108 | } 109 | 110 | private static void AutoTrackNumbers( List files, Mode mode ) 111 | { 112 | throw new NotImplementedException(); 113 | /* 114 | 115 | if( mode == Mode.Prompt ) 116 | { 117 | Console.WriteLine( "Confirm reset of track numbers. [A] All. [Y] Only missing. [N]. No." ); 118 | String option = Console.ReadLine().ToUpperInvariant(); 119 | if( option == "A" ) mode = Mode.All; 120 | else if( option == "Y" ) mode = Mode.Missing; 121 | else mode = Mode.Cancel; 122 | } 123 | 124 | if( mode == Mode.Cancel ) return; 125 | 126 | foreach( LoadedFile file in files ) 127 | { 128 | if( file.Tag.Track == 0 || mode == Mode.All ) 129 | { 130 | Match fileNameMatch = Values.FileNameTrackNumberRegex.Match( file.FileInfo.Name ); 131 | if( !fileNameMatch.Success ) 132 | { 133 | Console.WriteLine( "Couldn't match {0}", file.FileInfo.Name ); 134 | } 135 | else 136 | { 137 | UInt32 oldTrackNumber = file.Tag.Track; 138 | 139 | Int32 trackNumber = Int32.Parse( fileNameMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 140 | file.Tag.Track = (UInt32)trackNumber; 141 | file.IsModified = true; 142 | 143 | Console.WriteLine( "Updated \"{0}\". Track number {1} is now {2}.", file.FileInfo.Name, oldTrackNumber, file.Tag.Track ); 144 | } 145 | } 146 | } 147 | */ 148 | } 149 | 150 | private enum Mode 151 | { 152 | All, 153 | Missing, 154 | Prompt, 155 | Cancel 156 | } 157 | 158 | private static void SetAlbumArt( String directoryPath, List files, String imageFileName, Mode mode ) 159 | { 160 | if( String.IsNullOrWhiteSpace( imageFileName ) ) 161 | { 162 | Console.WriteLine( "Image file name not specified." ); 163 | return; 164 | } 165 | 166 | if( !Path.IsPathRooted( imageFileName ) ) imageFileName = Path.Combine( directoryPath, imageFileName ); 167 | 168 | if( !System.IO.File.Exists( imageFileName ) ) 169 | { 170 | Console.WriteLine( "Cannot find \"{0}\"", imageFileName ); 171 | return; 172 | } 173 | 174 | IPicture newPicture = new Picture( imageFileName ); 175 | 176 | if( mode == Mode.Prompt ) 177 | { 178 | Console.WriteLine( "Confirm reset of album art. [A] All. [Y] Only missing. [N]. No." ); 179 | String option = Console.ReadLine().ToUpperInvariant(); 180 | if( option == "A" ) mode = Mode.All; 181 | else if( option == "Y" ) mode = Mode.Missing; 182 | else mode = Mode.Cancel; 183 | } 184 | 185 | if( mode == Mode.Cancel ) return; 186 | 187 | foreach( LoadedFile file in files ) 188 | { 189 | IPicture[] pictures = file.Tag.Pictures; 190 | if( pictures == null || pictures.Length == 0 || mode == Mode.All ) 191 | { 192 | // https://stackoverflow.com/questions/7237346/having-trouble-writing-artwork-with-taglib-sharp-2-0-4-0-in-net 193 | if( pictures == null ) pictures = new IPicture[0]; 194 | Array.Resize( ref pictures, pictures.Length + 1 ); 195 | Int32 idx = pictures.GetUpperBound(0); 196 | pictures[idx] = newPicture; 197 | 198 | file.Tag.Pictures = pictures; 199 | file.IsModified = true; 200 | 201 | Console.WriteLine( "Updated \"{0}\". Now has {1} pictures.", file.FileInfo.Name, pictures.Length ); 202 | } 203 | else 204 | { 205 | Console.WriteLine( "Skipped \"{0}\". Already has {1} pictures.", file.FileInfo.Name, pictures.Length ); 206 | } 207 | } 208 | } 209 | 210 | private static void RemoveApe( List files ) 211 | { 212 | foreach( MpegLoadedFile mpegFile in files.OfType() ) 213 | { 214 | TagLib.Ape.Tag apeTag = (TagLib.Ape.Tag)mpegFile.MpegAudioFile.GetTag(TagTypes.Ape); 215 | if( apeTag != null ) 216 | { 217 | mpegFile.MpegAudioFile.RemoveTags( TagTypes.Ape ); 218 | mpegFile.IsModified = true; 219 | Console.WriteLine( "Removed APE tag from \"{0}\".", mpegFile.FileInfo.Name ); 220 | } 221 | } 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /TeslaTags.QuickFix/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle( "TeslaTags.QuickFix" )] 9 | [assembly: AssemblyDescription( "" )] 10 | [assembly: AssemblyConfiguration( "" )] 11 | [assembly: AssemblyCompany( "" )] 12 | [assembly: AssemblyProduct( "TeslaTags.QuickFix" )] 13 | [assembly: AssemblyCopyright( "Copyright © 2018" )] 14 | [assembly: AssemblyTrademark( "" )] 15 | [assembly: AssemblyCulture( "" )] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible( false )] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid( "00c86126-7a1e-49f0-86bf-22ec16a20aa8" )] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion( "1.0.*" )] 36 | //[assembly: AssemblyFileVersion( "1.0.0.0" )] 37 | -------------------------------------------------------------------------------- /TeslaTags.QuickFix/TeslaTags.QuickFix.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {00C86126-7A1E-49F0-86BF-22EC16A20AA8} 8 | Exe 9 | TeslaTags.QuickFix 10 | TeslaTags.QuickFix 11 | v4.7.1 12 | 512 13 | true 14 | 15 | 16 | AnyCPU 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 25 | 26 | AnyCPU 27 | pdbonly 28 | true 29 | bin\Release\ 30 | TRACE 31 | prompt 32 | 4 33 | 34 | 35 | 36 | ..\packages\taglib.2.1.0.0\lib\policy.2.0.taglib-sharp.dll 37 | 38 | 39 | 40 | 41 | 42 | ..\packages\taglib.2.1.0.0\lib\taglib-sharp.dll 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {831fe5d4-250f-4f55-9934-5072f2b7cbee} 56 | TeslaTags 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /TeslaTags.QuickFix/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | -------------------------------------------------------------------------------- /TeslaTags.Tests/DiscTrackNumberTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | 5 | namespace TeslaTags.Tests 6 | { 7 | [TestClass] 8 | public class DiscTrackNumberTests 9 | { 10 | private class TestCase 11 | { 12 | public TestCase(Int32? fileNameDisc, Int32? fileNameTrack, Int32? directoryDisc, Int32? directoryTrack, Int32? tagDisc, Int32? tagTrack, String fileName) 13 | { 14 | this.FileName = fileName; 15 | this.FileNameDisc = fileNameDisc; 16 | this.FileNameTrack = fileNameTrack; 17 | this.DirectoryDisc = directoryDisc; 18 | this.DirectoryTrack = directoryTrack; 19 | this.TagDisc = tagDisc; 20 | this.TagTrack = tagTrack; 21 | } 22 | 23 | public String FileName { get; } 24 | 25 | public Int32? FileNameDisc { get; } 26 | public Int32? FileNameTrack { get; } 27 | 28 | public Int32? DirectoryDisc { get; } 29 | public Int32? DirectoryTrack { get; } 30 | 31 | public Int32? TagDisc { get; } 32 | public Int32? TagTrack { get; } 33 | } 34 | 35 | private static readonly Int32? N = null; 36 | 37 | private static readonly TestCase[] _testCases = new TestCase[] 38 | { 39 | new TestCase( fileNameDisc: 1, fileNameTrack: 11, directoryDisc: 1, directoryTrack: 11, tagDisc: 1, tagTrack: 11, fileName: @"C:\Users\David\Music\Genesis\2004 - Platinum Collection\Disc 1\11 That's All.mp3" ), 40 | new TestCase( fileNameDisc: 1, fileNameTrack: 1, directoryDisc: 1, directoryTrack: 1, tagDisc: 1, tagTrack: 1, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2001 - Do Androids Dream of Electric Beats\Disc 1\01 - UNKLE - Intro.mp3" ), 41 | new TestCase( fileNameDisc: 3, fileNameTrack: 16, directoryDisc: 3, directoryTrack: 16, tagDisc: 3, tagTrack: 16, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2001 - Do Androids Dream of Electric Beats\Disc 3\16 - UNKLE - Rabbit In Your Headlights (UNKLEsounds Edit).mp3" ), 42 | new TestCase( fileNameDisc: N, fileNameTrack: 1, directoryDisc: N, directoryTrack: 1, tagDisc: 1, tagTrack: 1, fileName: @"C:\Users\David\Music\_Games\Grey Goo\01 - Grey Goo Main Theme.mp3" ), 43 | new TestCase( fileNameDisc: N, fileNameTrack: 18, directoryDisc: N, directoryTrack: 18, tagDisc: 1, tagTrack: 18, fileName: @"C:\Users\David\Music\_Games\Grey Goo\18 - War Has Given You a Voice Scene05 Outro.mp3" ), // note the "Scene05", it should not be interpreted as a track or disc number 44 | new TestCase( fileNameDisc: N, fileNameTrack: 19, directoryDisc: N, directoryTrack: 19, tagDisc: 2, tagTrack: 1, fileName: @"C:\Users\David\Music\_Games\Grey Goo\19 - The Humans.mp3" ), // This file already has tags saying Disc=2,Track=1 45 | new TestCase( fileNameDisc: N, fileNameTrack: 34, directoryDisc: N, directoryTrack: 34, tagDisc: 2, tagTrack: 16, fileName: @"C:\Users\David\Music\_Games\Grey Goo\34 - Catalyst Detonation Scene10 Outro.mp3" ), // This file already has tags saying Disc=2,Track=16 46 | new TestCase( fileNameDisc: N, fileNameTrack: 35, directoryDisc: N, directoryTrack: 35, tagDisc: 3, tagTrack: 1, fileName: @"C:\Users\David\Music\_Games\Grey Goo\35 - The Goo.mp3" ), // This file already has tags saying Disc=3,Track=1 47 | new TestCase( fileNameDisc: N, fileNameTrack: 50, directoryDisc: N, directoryTrack: 50, tagDisc: 3, tagTrack: 16, fileName: @"C:\Users\David\Music\_Games\Grey Goo\50 - War is Evolving.mp3" ), // This file already has tags saying Disc=3,Track=16 48 | new TestCase( fileNameDisc: 1, fileNameTrack: 1, directoryDisc: 1, directoryTrack: 1, tagDisc: 1, tagTrack: 1, fileName: @"C:\Users\David\Music\_Various Artists\100 Hits - The Best Rock and Power Ballads\1-01 Bat Out Of Hell.mp3" ), 49 | new TestCase( fileNameDisc: 1, fileNameTrack: 20, directoryDisc: 1, directoryTrack: 20, tagDisc: 1, tagTrack: 20, fileName: @"C:\Users\David\Music\_Various Artists\100 Hits - The Best Rock and Power Ballads\1-20 When I See You Smile.mp3" ), 50 | new TestCase( fileNameDisc: 2, fileNameTrack: 1, directoryDisc: 2, directoryTrack: 1, tagDisc: 2, tagTrack: 1, fileName: @"C:\Users\David\Music\_Various Artists\100 Hits - The Best Rock and Power Ballads\2-01 Carry On Wayward Son.mp3" ), 51 | new TestCase( fileNameDisc: 2, fileNameTrack: 20, directoryDisc: 2, directoryTrack: 20, tagDisc: 2, tagTrack: 20, fileName: @"C:\Users\David\Music\_Various Artists\100 Hits - The Best Rock and Power Ballads\2-20 Rock And Roll Dreams Come Throu.mp3" ), 52 | new TestCase( fileNameDisc: 5, fileNameTrack: 20, directoryDisc: 5, directoryTrack: 20, tagDisc: 5, tagTrack: 20, fileName: @"C:\Users\David\Music\_Various Artists\100 Hits - The Best Rock and Power Ballads\5-20 Dancing In The Moonlight.mp3" ), 53 | new TestCase( fileNameDisc: 1, fileNameTrack: 1, directoryDisc: 1, directoryTrack: 1, tagDisc: 1, tagTrack: 1, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2005 - Edit Music for a Film\Disc 1\Disc 1 - Widescreen Edit - A New Hope - 01 - Intro_ 20th Century Fox, Money_Power_Respect, THX Deep Note.mp3" ), // note the "20" in the filename in addition to "01" 54 | new TestCase( fileNameDisc: 1, fileNameTrack: 15, directoryDisc: 1, directoryTrack: 15, tagDisc: 1, tagTrack: 15, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2005 - Edit Music for a Film\Disc 1\Disc 1 - Widescreen Edit - A New Hope - 15 - The Second Star to the Right.mp3" ), 55 | new TestCase( fileNameDisc: 2, fileNameTrack: 1, directoryDisc: 2, directoryTrack: 1, tagDisc: 2, tagTrack: 1, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2005 - Edit Music for a Film\Disc 2\Disc 2 - Bonus Material Edit - Strikes Back - 01 - MGM, Lost in Translation, 2010.mp3" ), 56 | new TestCase( fileNameDisc: 2, fileNameTrack: 7, directoryDisc: 2, directoryTrack: 7, tagDisc: 2, tagTrack: 7, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2005 - Edit Music for a Film\Disc 2\Disc 2 - Bonus Material Edit - Strikes Back - 07 - Dylan Rhymes - The Way, Assault on Precinct 13 Radio Spot.mp3" ), 57 | new TestCase( fileNameDisc: N, fileNameTrack: 8, directoryDisc: N, directoryTrack: 8, tagDisc: N, tagTrack: 8, fileName: @"C:\Users\David\Music\_Games\MDK\2001 - MDK2\track-08.mp3" ), 58 | new TestCase( fileNameDisc: N, fileNameTrack: N, directoryDisc: N, directoryTrack: N, tagDisc: N, tagTrack: N, fileName: @"C:\Users\David\Music\_Various Artists\_Downloaded\IndieRock\Tube Tops 2000 - Rock and Roll, Part 2.mp3" ), 59 | new TestCase( fileNameDisc: N, fileNameTrack: 0, directoryDisc: N, directoryTrack: 0, tagDisc: N, tagTrack: 0, fileName: @"C:\Users\David\Music\Lemon Jelly\2005-01-31 - '64-'95\00 - Yes.mp3" ), // note the use of "0" 60 | new TestCase( fileNameDisc: 1, fileNameTrack: 12, directoryDisc: 1, directoryTrack: 12, tagDisc: 1, tagTrack: 12, fileName: @"C:\Users\David\Music\Unkle\UNKLESounds - 2015 - Global Underground 41 Naples\1-12 …Like Clockwork (UNKLE Remix).mp3" ) // note the many digits in the album name 61 | }; 62 | 63 | [TestMethod] 64 | public void TestDiscTrackNumberExtractionFromFileNameOnly() 65 | { 66 | Int32 i = 0; 67 | foreach( TestCase testCase in _testCases ) 68 | { 69 | (Int32? actualDisc, Int32? actualTrack, String err) = DiscAndTrackNumberHelper.GetDiscTrackNumberFromFileName( testCase.FileName, checkSiblings: false ); 70 | 71 | //Assert.IsNull( err ); 72 | Boolean fnContainsDigits = System.IO.Path.GetFileNameWithoutExtension( testCase.FileName ).Any( c => Char.IsDigit(c) ); 73 | if( testCase.FileNameDisc == null && testCase.FileNameTrack == null && fnContainsDigits ) 74 | { 75 | Assert.IsNotNull( err, "Expected error for \"" + testCase.FileName + "\"." ); 76 | } 77 | else 78 | { 79 | Assert.IsNull( err, "Expected no error for \"" + testCase.FileName + "\"." ); 80 | } 81 | 82 | Int32? expectedDisc = testCase.FileNameDisc; 83 | Int32? expectedTrack = testCase.FileNameTrack; 84 | 85 | Assert.AreEqual( expectedDisc , actualDisc , "Disc number" ); 86 | Assert.AreEqual( expectedTrack, actualTrack, "Track number" ); 87 | 88 | i++; 89 | } 90 | } 91 | 92 | [TestMethod] 93 | public void TestDiscTrackNumberExtractionFromFileNameInFolderWithSiblings() 94 | { 95 | Int32 i = 0; 96 | foreach( TestCase testCase in _testCases ) 97 | { 98 | (Int32? actualDisc, Int32? actualTrack, String err) = DiscAndTrackNumberHelper.GetDiscTrackNumberFromFileName( testCase.FileName, checkSiblings: true ); 99 | 100 | //Assert.IsNull( err ); 101 | Boolean fnContainsDigits = System.IO.Path.GetFileNameWithoutExtension( testCase.FileName ).Any( c => Char.IsDigit(c) ); 102 | if( testCase.FileNameDisc == null && testCase.FileNameTrack == null && fnContainsDigits ) 103 | { 104 | Assert.IsNotNull( err, "Expected error for \"" + testCase.FileName + "\"." ); 105 | } 106 | else 107 | { 108 | Assert.IsNull( err, "Expected no error for \"" + testCase.FileName + "\"." ); 109 | } 110 | 111 | Int32? expectedDisc = testCase.DirectoryDisc; 112 | Int32? expectedTrack = testCase.DirectoryTrack; 113 | 114 | Assert.AreEqual( expectedDisc , actualDisc , "Disc number" ); 115 | Assert.AreEqual( expectedTrack, actualTrack, "Track number" ); 116 | 117 | i++; 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /TeslaTags.Tests/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | [assembly: AssemblyTitle( "TeslaTags.Tests" )] 6 | [assembly: AssemblyDescription( "" )] 7 | [assembly: AssemblyConfiguration( "" )] 8 | [assembly: AssemblyCompany( "" )] 9 | [assembly: AssemblyProduct( "TeslaTags.Tests" )] 10 | [assembly: AssemblyCopyright( "Copyright © 2018" )] 11 | [assembly: AssemblyTrademark( "" )] 12 | [assembly: AssemblyCulture( "" )] 13 | 14 | [assembly: ComVisible( false )] 15 | 16 | [assembly: Guid( "75366cb1-75c2-46c0-81fb-25d864e850ed" )] 17 | 18 | // [assembly: AssemblyVersion("1.0.*")] 19 | [assembly: AssemblyVersion( "1.0.0.0" )] 20 | [assembly: AssemblyFileVersion( "1.0.0.0" )] 21 | -------------------------------------------------------------------------------- /TeslaTags.Tests/TeslaTags.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {75366CB1-75C2-46C0-81FB-25D864E850ED} 8 | Library 9 | Properties 10 | TeslaTags.Tests 11 | TeslaTags.Tests 12 | v4.7.1 13 | 512 14 | {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 15 | 15.0 16 | $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) 17 | $(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages 18 | False 19 | UnitTest 20 | 21 | 22 | 23 | 24 | true 25 | full 26 | false 27 | bin\Debug\ 28 | DEBUG;TRACE 29 | prompt 30 | 4 31 | 32 | 33 | pdbonly 34 | true 35 | bin\Release\ 36 | TRACE 37 | prompt 38 | 4 39 | 40 | 41 | 42 | ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.dll 43 | 44 | 45 | ..\packages\MSTest.TestFramework.1.3.2\lib\net45\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {831fe5d4-250f-4f55-9934-5072f2b7cbee} 57 | TeslaTags 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /TeslaTags.Tests/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /TeslaTags.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27703.2018 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaTags", "TeslaTags\TeslaTags.csproj", "{831FE5D4-250F-4F55-9934-5072F2B7CBEE}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaTags.Gui", "TeslaTags.Gui\TeslaTags.Gui.csproj", "{624055F2-0408-49EA-A6C3-F59EE7DBA1CF}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaTags.QuickFix", "TeslaTags.QuickFix\TeslaTags.QuickFix.csproj", "{00C86126-7A1E-49F0-86BF-22EC16A20AA8}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeslaTags.Tests", "TeslaTags.Tests\TeslaTags.Tests.csproj", "{75366CB1-75C2-46C0-81FB-25D864E850ED}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{39F3288A-EA9E-4729-8872-EFC9A61BCEA6}" 15 | ProjectSection(SolutionItems) = preProject 16 | README.md = README.md 17 | EndProjectSection 18 | EndProject 19 | Global 20 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 21 | Debug|Any CPU = Debug|Any CPU 22 | Release|Any CPU = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {831FE5D4-250F-4F55-9934-5072F2B7CBEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {831FE5D4-250F-4F55-9934-5072F2B7CBEE}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {831FE5D4-250F-4F55-9934-5072F2B7CBEE}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {831FE5D4-250F-4F55-9934-5072F2B7CBEE}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {624055F2-0408-49EA-A6C3-F59EE7DBA1CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {624055F2-0408-49EA-A6C3-F59EE7DBA1CF}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {624055F2-0408-49EA-A6C3-F59EE7DBA1CF}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {624055F2-0408-49EA-A6C3-F59EE7DBA1CF}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {00C86126-7A1E-49F0-86BF-22EC16A20AA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 34 | {00C86126-7A1E-49F0-86BF-22EC16A20AA8}.Debug|Any CPU.Build.0 = Debug|Any CPU 35 | {00C86126-7A1E-49F0-86BF-22EC16A20AA8}.Release|Any CPU.ActiveCfg = Release|Any CPU 36 | {00C86126-7A1E-49F0-86BF-22EC16A20AA8}.Release|Any CPU.Build.0 = Release|Any CPU 37 | {75366CB1-75C2-46C0-81FB-25D864E850ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {75366CB1-75C2-46C0-81FB-25D864E850ED}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {75366CB1-75C2-46C0-81FB-25D864E850ED}.Release|Any CPU.ActiveCfg = Release|Any CPU 40 | {75366CB1-75C2-46C0-81FB-25D864E850ED}.Release|Any CPU.Build.0 = Release|Any CPU 41 | EndGlobalSection 42 | GlobalSection(SolutionProperties) = preSolution 43 | HideSolutionNode = FALSE 44 | EndGlobalSection 45 | GlobalSection(ExtensibilityGlobals) = postSolution 46 | SolutionGuid = {2ED6C957-2F54-44DC-B777-9303B3B49874} 47 | EndGlobalSection 48 | EndGlobal 49 | -------------------------------------------------------------------------------- /TeslaTags/App.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TeslaTags/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | 6 | namespace TeslaTags 7 | { 8 | public static partial class Extensions 9 | { 10 | public static String FormatCurrent(this String format, Object arg0) 11 | { 12 | return String.Format( CultureInfo.CurrentCulture, format, arg0 ); 13 | } 14 | 15 | public static String FormatCurrent(this String format, Object arg0, Object arg1) 16 | { 17 | return String.Format( CultureInfo.CurrentCulture, format, arg0, arg1 ); 18 | } 19 | 20 | public static String FormatCurrent(this String format, Object arg0, Object arg1, Object arg2) 21 | { 22 | return String.Format( CultureInfo.CurrentCulture, format, arg0, arg1, arg2 ); 23 | } 24 | 25 | public static String FormatCurrent(this String format, params Object[] args) 26 | { 27 | return String.Format( CultureInfo.CurrentCulture, format, args ); 28 | } 29 | 30 | // 31 | 32 | public static String FormatInvariant(this String format, Object arg0) 33 | { 34 | return String.Format( CultureInfo.InvariantCulture, format, arg0 ); 35 | } 36 | 37 | public static String FormatInvariant(this String format, Object arg0, Object arg1) 38 | { 39 | return String.Format( CultureInfo.InvariantCulture, format, arg0, arg1 ); 40 | } 41 | 42 | public static String FormatInvariant(this String format, Object arg0, Object arg1, Object arg2) 43 | { 44 | return String.Format( CultureInfo.InvariantCulture, format, arg0, arg1, arg2 ); 45 | } 46 | 47 | public static String FormatInvariant(this String format, params Object[] args) 48 | { 49 | return String.Format( CultureInfo.InvariantCulture, format, args ); 50 | } 51 | 52 | // 53 | 54 | /// Performs an ordinal case-insensitive equality check. 55 | public static Boolean EqualsCI( this String x, String y ) 56 | { 57 | return String.Equals( x, y, StringComparison.OrdinalIgnoreCase ); 58 | } 59 | 60 | public static void AddRange(this ICollection collection, IEnumerable items) 61 | { 62 | if( items != null ) 63 | { 64 | foreach( T item in items ) collection.Add( item ); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /TeslaTags/Files/FlacLoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | using TagLib; 6 | using flac = TagLib.Flac; 7 | 8 | namespace TeslaTags 9 | { 10 | public sealed class FlacLoadedFile : TagLibLoadedFile 11 | { 12 | public static Boolean TryCreate( FileInfo fileInfo, flac.File flacFile, List messages, out LoadedFile loadedFile ) 13 | { 14 | flac.Metadata flacTag = (flac.Metadata)flacFile.GetTag(TagTypes.FlacMetadata); 15 | if( flacTag == null ) 16 | { 17 | messages.AddFileError( fileInfo.FullName, "Does not contain FLAC metadata." ); 18 | loadedFile = default; 19 | return false; 20 | } 21 | 22 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 23 | 24 | loadedFile = new FlacLoadedFile( fileInfo, flacFile, flacTag, recoveryTag ); 25 | return true; 26 | } 27 | 28 | private FlacLoadedFile( FileInfo fileInfo, flac.File flacFile, Tag tag, RecoveryTag recoveryTag ) 29 | : base( fileInfo, flacFile, tag, recoveryTag ) 30 | { 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /TeslaTags/Files/GenericId3LoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | using id3v1 = TagLib.Id3v1; 5 | using id3v2 = TagLib.Id3v2; 6 | using ape = TagLib.Ape; 7 | using System.IO; 8 | 9 | namespace TeslaTags 10 | { 11 | public sealed class GenericId3LoadedFile : LoadedFile 12 | { 13 | public static Boolean TryCreate( FileInfo fileInfo, TagLib.File file, List messages, out LoadedFile loadedFile ) 14 | { 15 | // Many files use ID3v1 and ID3v2 and APE besides MP3. Try them all here. 16 | 17 | // Note that `file.GetTag( TagTypes.Id3v1 )` may work while `file.GetTag( TagTypes.AllTags )` returns null - the TagLib library is weird. 18 | 19 | { 20 | TagLib.Tag id3v2TagMaybe = file.GetTag( TagLib.TagTypes.Id3v2, create: false ); 21 | if( id3v2TagMaybe != null ) 22 | { 23 | if( id3v2TagMaybe is id3v2.Tag id3v2Tag ) 24 | { 25 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 26 | 27 | loadedFile = new GenericId3LoadedFile( fileInfo, file, id3v2Tag, recoveryTag ); 28 | return true; 29 | } 30 | else 31 | { 32 | messages.AddFileError( fileInfo.FullName, "Expected " + typeof(id3v2.Tag).FullName + " but TagLib.File.GetTag( Id3v2 ) returned " + id3v2TagMaybe.GetType().FullName ); 33 | 34 | loadedFile = default; 35 | return false; 36 | } 37 | } 38 | } 39 | 40 | { 41 | TagLib.Tag id3v1TagMaybe = file.GetTag( TagLib.TagTypes.Id3v1, create: false ); 42 | if( id3v1TagMaybe != null ) 43 | { 44 | if( id3v1TagMaybe is id3v1.Tag id3v1Tag ) 45 | { 46 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 47 | 48 | loadedFile = new GenericId3LoadedFile( fileInfo, file, id3v1Tag, recoveryTag ); 49 | return true; 50 | } 51 | else 52 | { 53 | messages.AddFileError( fileInfo.FullName, "Expected " + typeof(id3v1.Tag).FullName + " but TagLib.File.GetTag( Id3v1 ) returned " + id3v1TagMaybe.GetType().FullName ); 54 | 55 | loadedFile = default; 56 | return false; 57 | } 58 | } 59 | } 60 | 61 | loadedFile = default; 62 | return false; 63 | } 64 | 65 | private GenericId3LoadedFile( FileInfo fileInfo, TagLib.File file, TagLib.Tag tag, RecoveryTag recoveryTag ) 66 | : base( fileInfo, tag, recoveryTag ) 67 | { 68 | this.TagLibFile = file; 69 | } 70 | 71 | public TagLib.File TagLibFile { get; } 72 | 73 | public override void Save( List messages ) 74 | { 75 | this.TagLibFile.Save(); 76 | SaveRecoveryTagToJsonFile( this.FileInfo, this.RecoveryTag, messages ); 77 | } 78 | 79 | protected override void Dispose( Boolean disposing ) 80 | { 81 | if( disposing ) 82 | { 83 | this.TagLibFile.Dispose(); 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /TeslaTags/Files/LoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | using Newtonsoft.Json; 6 | 7 | using TagLib; 8 | 9 | using aiff = TagLib.Aiff; 10 | using asf = TagLib.Asf; 11 | using audi = TagLib.Audible; 12 | using matr = TagLib.Matroska; 13 | using mpeg = TagLib.Mpeg; 14 | using mp4 = TagLib.Mpeg4; 15 | using aac = TagLib.Aac; 16 | using ape = TagLib.Ape; 17 | using flac = TagLib.Flac; 18 | using muse = TagLib.MusePack; 19 | using wavp = TagLib.WavPack; 20 | using ogg = TagLib.Ogg; 21 | using riff = TagLib.Riff; 22 | 23 | namespace TeslaTags 24 | { 25 | using RecoveryDict = Dictionary; 26 | 27 | public abstract class LoadedFile : IDisposable 28 | { 29 | #region Static factory 30 | 31 | public static Boolean TryLoadFromFile( FileInfo fileInfo, List messages, out LoadedFile loadedFile ) 32 | { 33 | if( fileInfo == null ) throw new ArgumentNullException(nameof(fileInfo)); 34 | if( messages == null ) throw new ArgumentNullException(nameof(messages)); 35 | 36 | // 37 | 38 | TagLib.File tagLibFile; 39 | try 40 | { 41 | tagLibFile = TagLib.File.Create( fileInfo.FullName ); // Never returns null. 42 | } 43 | catch( UnsupportedFormatException ufEx ) 44 | { 45 | messages.AddFileError( fileInfo.FullName, "Unsupported format: " + ufEx.Message ); 46 | loadedFile = null; 47 | return false; 48 | } 49 | catch( CorruptFileException cfEx ) 50 | { 51 | messages.AddFileError( fileInfo.FullName, "Corrupted file: " + cfEx.Message ); 52 | loadedFile = null; 53 | return false; 54 | } 55 | catch( Exception ex ) 56 | { 57 | messages.AddFileError( fileInfo.FullName, "Could not load file: " + ex.Message ); 58 | loadedFile = null; 59 | return false; 60 | } 61 | 62 | // 63 | 64 | Boolean isLoaded = false; 65 | try 66 | { 67 | isLoaded = TryCreateFromTagLibFile( fileInfo, tagLibFile, messages, out loadedFile ); 68 | return isLoaded; 69 | } 70 | finally 71 | { 72 | if( !isLoaded ) 73 | { 74 | tagLibFile.Dispose(); 75 | } 76 | } 77 | } 78 | 79 | private static Boolean TryCreateFromTagLibFile( FileInfo fileInfo, TagLib.File tagLibFile, List messages, out LoadedFile loadedFile ) 80 | { 81 | switch( tagLibFile ) 82 | { 83 | case aiff.File aiffFile: 84 | return GenericId3LoadedFile.TryCreate( fileInfo, aiffFile, messages, out loadedFile ); // AIFF uses Id3v2. 85 | 86 | case mpeg.AudioFile mpegAudioFile: 87 | return MpegLoadedFile.TryCreate( fileInfo, mpegAudioFile, messages, out loadedFile ); 88 | 89 | case mpeg.File mpegFile: 90 | return GenericId3LoadedFile.TryCreate( fileInfo, mpegFile, messages, out loadedFile ); // MPEG files can optionally use Id3v2, Id3v1, or APE. 91 | 92 | case mp4.File mp4File: 93 | return Mp4LoadedFile.TryCreate( fileInfo, mp4File, messages, out loadedFile ); 94 | 95 | case aac.File aacFile: 96 | return GenericId3LoadedFile.TryCreate( fileInfo, aacFile, messages, out loadedFile ); // AAC uses Id3v2, Id3v1, or APE. 97 | 98 | case flac.File flacFile: 99 | return FlacLoadedFile.TryCreate( fileInfo, flacFile, messages, out loadedFile ); 100 | 101 | case ape.File apeFile: // Tesla doesn't support the APE file format, and especially not APE-format tags when present in other files like MP3. While APE files can contain Id3v2 tags, it's moot. 102 | return GenericId3LoadedFile.TryCreate( fileInfo, apeFile, messages, out loadedFile ); // AIFF optionally uses Id3v2, Id3v1, or APE. 103 | 104 | case muse.File museFile: 105 | return GenericId3LoadedFile.TryCreate( fileInfo, museFile, messages, out loadedFile ); // MUSE optionally uses Id3v2, Id3v1, or APE. 106 | 107 | case wavp.File wavpFile: 108 | return GenericId3LoadedFile.TryCreate( fileInfo, wavpFile, messages, out loadedFile ); // WavPack optionally uses Id3v2, Id3v1, or APE. 109 | 110 | case ogg.File oggFile: 111 | return OggLoadedFile.TryCreate( fileInfo, oggFile, messages, out loadedFile ); 112 | 113 | case riff.File riffFile: // RIFF has its own tag format. 114 | case asf.File asfFile: // ASF has its own tag format. 115 | case audi.File audibleFile: // Audible has its own tag format. 116 | case matr.File matroskaFile: // Matroska has its own tag format. 117 | default: 118 | messages.AddFileWarning( fileInfo.FullName, "Unsupported TagLib file type ({0}).", tagLibFile.GetType().FullName ); 119 | loadedFile = default; 120 | return false; 121 | } 122 | } 123 | 124 | protected static T Load( FileInfo fileInfo, List messages ) 125 | where T : TagLib.File 126 | { 127 | TagLib.File file; 128 | try 129 | { 130 | file = TagLib.File.Create( fileInfo.FullName ); 131 | } 132 | catch(Exception ex) 133 | { 134 | messages.AddFileError( fileInfo.FullName, "Could not load file: " + ex.Message ); 135 | return null; 136 | } 137 | 138 | if( file == null ) 139 | { 140 | messages.AddFileError( fileInfo.FullName, "Could not load file: TagLib.File.Create() returned null." ); 141 | return null; 142 | } 143 | 144 | Boolean anyErrors = messages.AddFileCorruptionErrors( fileInfo.FullName, file.CorruptionReasons ); 145 | if( !anyErrors ) 146 | { 147 | if( file is T typedFile ) 148 | { 149 | return typedFile; 150 | } 151 | else 152 | { 153 | messages.AddFileError( fileInfo.FullName, "Could not load file. Expected " + typeof(T).FullName + " but TagLib returned " + file.GetType().FullName + "." ); 154 | return null; 155 | } 156 | } 157 | 158 | file.Dispose(); 159 | return null; 160 | } 161 | 162 | private static (String jsonFileName, RecoveryDict dict) GetRecoveryDict( FileInfo fileInfo, List messages ) 163 | { 164 | // Does the file exist? 165 | String directoryPath = fileInfo.DirectoryName; 166 | String jsonFileName = Path.Combine( directoryPath, "TeslaTagsRecovery.json" ); 167 | if( !System.IO.File.Exists( jsonFileName ) ) return ( jsonFileName, null ); 168 | 169 | String jsonFile = System.IO.File.ReadAllText( jsonFileName ); 170 | 171 | RecoveryDict dict = new RecoveryDict( StringComparer.OrdinalIgnoreCase ); 172 | try 173 | { 174 | JsonConvert.PopulateObject( jsonFile, dict ); 175 | 176 | return ( jsonFileName, dict ); 177 | } 178 | catch( JsonException je ) 179 | { 180 | messages.AddFileWarning( fileInfo.FullName, "Couldn't load recovery information from \"" + jsonFileName + "\". Message: " + je.Message ); 181 | return ( jsonFileName, null ); 182 | } 183 | } 184 | 185 | protected static RecoveryTag LoadRecoveryTagFromJsonFile( FileInfo fileInfo, List messages ) 186 | { 187 | var (jsonFileName, dict) = GetRecoveryDict( fileInfo, messages ); 188 | 189 | if( dict == null ) return null; 190 | 191 | if( dict.TryGetValue( fileInfo.Name, out RecoveryTag tag ) ) return tag; 192 | 193 | return null; 194 | } 195 | 196 | protected static void SaveRecoveryTagToJsonFile( FileInfo fileInfo, RecoveryTag tag, List messages ) 197 | { 198 | if( tag == null || tag.IsEmpty ) return; 199 | 200 | var (jsonFileName, dict) = GetRecoveryDict( fileInfo, messages ); 201 | 202 | if( dict == null ) dict = new RecoveryDict(); 203 | 204 | dict[ fileInfo.Name ] = tag; 205 | 206 | String jsonFile = JsonConvert.SerializeObject( dict, Formatting.Indented ); 207 | System.IO.File.WriteAllText( jsonFileName, jsonFile ); 208 | } 209 | 210 | #endregion 211 | 212 | protected LoadedFile( FileInfo fileInfo, Tag tag, RecoveryTag recoveryTag ) 213 | { 214 | this.FileInfo = fileInfo; 215 | this.Tag = tag; 216 | this.RecoveryTag = recoveryTag ?? new RecoveryTag(); 217 | } 218 | 219 | public void Dispose() 220 | { 221 | this.Dispose( disposing: true ); 222 | GC.SuppressFinalize( this ); 223 | } 224 | 225 | protected abstract void Dispose(Boolean disposing); 226 | 227 | public abstract void Save( List messages ); 228 | 229 | public FileInfo FileInfo { get; } 230 | public Tag Tag { get; } 231 | 232 | public Boolean IsModified { get; set; } 233 | 234 | public RecoveryTag RecoveryTag { get; } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /TeslaTags/Files/Mp4LoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | using TagLib; 9 | 10 | using mpeg = TagLib.Mpeg; 11 | using aac = TagLib.Aac; 12 | using mpeg4 = TagLib.Mpeg4; 13 | using id3v2 = TagLib.Id3v2; 14 | 15 | namespace TeslaTags 16 | { 17 | public sealed class Mp4LoadedFile : TagLibLoadedFile 18 | { 19 | // MP4 files can have either Apple tags or "ISO" tags. We don't support ISO tags. 20 | 21 | public static Boolean TryCreate( FileInfo fileInfo, mpeg4.File mp4File, List messages, out LoadedFile loadedFile ) 22 | { 23 | mpeg4.AppleTag appleTag = (mpeg4.AppleTag)mp4File.GetTag( TagTypes.Apple ); 24 | if( appleTag == null ) 25 | { 26 | messages.AddFileError( fileInfo.FullName, "Does not contain an Apple tag." ); 27 | loadedFile = default; 28 | return false; 29 | } 30 | else 31 | { 32 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 33 | 34 | loadedFile = new Mp4LoadedFile( fileInfo, mp4File, appleTag, recoveryTag ); 35 | return true; 36 | } 37 | } 38 | 39 | private Mp4LoadedFile( FileInfo fileInfo, mpeg4.File mp4File, Tag tag, RecoveryTag recoveryTag ) 40 | : base( fileInfo, mp4File, tag, null ) 41 | { 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /TeslaTags/Files/MpegLoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text; 6 | 7 | using Newtonsoft.Json; 8 | 9 | using TagLib; 10 | 11 | using mpeg = TagLib.Mpeg; 12 | using id3v2 = TagLib.Id3v2; 13 | 14 | namespace TeslaTags 15 | { 16 | public sealed class MpegLoadedFile : LoadedFile 17 | { 18 | private const Boolean _usePrivateFrameForMp3RecoveryData = true; 19 | 20 | // Private frames' "Owner" tag should be a URI or email address: 21 | // http://id3.org/id3v2.3.0#Private_frame 22 | private const String teslaTagsPrivateTagOwner = "https://github.com/Jehoel/TeslaTags"; 23 | 24 | public static Boolean TryCreate( FileInfo fileInfo, mpeg.AudioFile mpegAudioFile, List messages, out LoadedFile loadedFile ) 25 | { 26 | id3v2.Tag id3v2Tag = (id3v2.Tag)mpegAudioFile.GetTag(TagTypes.Id3v2); 27 | if( id3v2Tag == null ) 28 | { 29 | messages.AddFileError( fileInfo.FullName, "Does not contain ID3v2 tag." ); 30 | loadedFile = default; 31 | return false; 32 | } 33 | 34 | // Load recovery tag: 35 | RecoveryTag recoveryTag = null; 36 | if( _usePrivateFrameForMp3RecoveryData ) 37 | { 38 | id3v2.PrivateFrame recoveryTagFrame = id3v2Tag 39 | .GetFrames() 40 | .Where( pf => pf.Owner == teslaTagsPrivateTagOwner ) 41 | .FirstOrDefault(); 42 | 43 | if( recoveryTagFrame != null ) 44 | { 45 | String recoveryTagJsonStr = recoveryTagFrame.PrivateData.ToString( StringType.UTF8 ); 46 | recoveryTag = JsonConvert.DeserializeObject( recoveryTagJsonStr ); 47 | } 48 | else 49 | { 50 | recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 51 | } 52 | } 53 | 54 | if( recoveryTag == null ) 55 | { 56 | recoveryTag = new RecoveryTag(); 57 | } 58 | 59 | loadedFile = new MpegLoadedFile( fileInfo, mpegAudioFile, id3v2Tag, recoveryTag ); 60 | return true; 61 | } 62 | 63 | private MpegLoadedFile( FileInfo fileInfo, mpeg.AudioFile mpegAudioFile, Tag tag, RecoveryTag recoveryTag ) 64 | : base( fileInfo, tag, recoveryTag ) 65 | { 66 | this.MpegAudioFile = mpegAudioFile; 67 | } 68 | 69 | protected sealed override void Dispose(Boolean disposing) 70 | { 71 | if( disposing ) 72 | { 73 | this.MpegAudioFile.Dispose(); 74 | } 75 | } 76 | 77 | public override void Save(List messages) 78 | { 79 | if( this.RecoveryTag.IsSet ) 80 | { 81 | if( _usePrivateFrameForMp3RecoveryData ) 82 | { 83 | id3v2.Tag id3v2Tag = (id3v2.Tag)this.MpegAudioFile.GetTag(TagTypes.Id3v2); 84 | 85 | id3v2.PrivateFrame recoveryTagFrame = id3v2Tag 86 | .GetFrames() 87 | .Where( pf => pf.Owner == teslaTagsPrivateTagOwner ) 88 | .FirstOrDefault(); 89 | 90 | if( recoveryTagFrame == null ) 91 | { 92 | recoveryTagFrame = new id3v2.PrivateFrame( teslaTagsPrivateTagOwner ); 93 | id3v2Tag.AddFrame( recoveryTagFrame ); 94 | } 95 | 96 | String newRecoveryTagJsonStr = JsonConvert.SerializeObject( this.RecoveryTag ); 97 | Byte[] newRecoveryTagJsonStrBytes = Encoding.UTF8.GetBytes( newRecoveryTagJsonStr ); 98 | 99 | recoveryTagFrame.PrivateData = new ByteVector( newRecoveryTagJsonStrBytes ); 100 | } 101 | 102 | // Also save to JSON file: 103 | SaveRecoveryTagToJsonFile( this.FileInfo, this.RecoveryTag, messages ); 104 | } 105 | 106 | this.MpegAudioFile.Save(); 107 | } 108 | 109 | public mpeg.AudioFile MpegAudioFile { get; } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /TeslaTags/Files/OggLoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | using TagLib; 6 | using ogg = TagLib.Ogg; 7 | 8 | namespace TeslaTags 9 | { 10 | public sealed class OggLoadedFile : TagLibLoadedFile 11 | { 12 | public static Boolean TryCreate( FileInfo fileInfo, ogg.File oggFile, List messages, out LoadedFile loadedFile ) 13 | { 14 | ogg.XiphComment oggTag = (ogg.XiphComment)oggFile.GetTag(TagTypes.Xiph); 15 | if( oggTag == null ) 16 | { 17 | messages.AddFileError( fileInfo.FullName, "Does not contain XIPH comment data." ); 18 | loadedFile = default; 19 | return false; 20 | } 21 | else 22 | { 23 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 24 | 25 | loadedFile = new OggLoadedFile( fileInfo, oggFile, oggTag, recoveryTag ); 26 | return true; 27 | } 28 | } 29 | 30 | private OggLoadedFile( FileInfo fileInfo, ogg.File oggFile, Tag tag, RecoveryTag recoveryTag ) 31 | : base( fileInfo, oggFile, tag, null ) 32 | { 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /TeslaTags/Files/RiffLoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | using TagLib; 6 | using riff = TagLib.Riff; 7 | 8 | namespace TeslaTags 9 | { 10 | public sealed class RiffLoadedFile : TagLibLoadedFile 11 | { 12 | public static RiffLoadedFile Create( FileInfo fileInfo, List messages ) 13 | { 14 | riff.File riffFile = Load( fileInfo, messages ); 15 | if( riffFile == null ) return null; 16 | 17 | riff.InfoTag riffTag = (riff.InfoTag)riffFile.GetTag(TagTypes.RiffInfo); 18 | if( riffTag == null ) 19 | { 20 | messages.AddFileError( fileInfo.FullName, "Does not contain RIFF info data." ); 21 | riffFile.Dispose(); 22 | return null; 23 | } 24 | 25 | RecoveryTag recoveryTag = LoadRecoveryTagFromJsonFile( fileInfo, messages ); 26 | 27 | return new RiffLoadedFile( fileInfo, riffFile, riffTag, recoveryTag ); 28 | } 29 | 30 | private RiffLoadedFile( FileInfo fileInfo, riff.File riffFile, Tag tag, RecoveryTag recoveryTag ) 31 | : base( fileInfo, riffFile, tag, recoveryTag ) 32 | { 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /TeslaTags/Files/TagLibLoadedFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | using TagLib; 6 | 7 | namespace TeslaTags 8 | { 9 | public abstract class TagLibLoadedFile : LoadedFile 10 | where TFile : TagLib.File 11 | { 12 | protected TagLibLoadedFile( FileInfo fileInfo, TFile tagLibFile, Tag tag, RecoveryTag recoveryTag ) 13 | : base( fileInfo, tag, recoveryTag ) 14 | { 15 | this.TagLibFile = tagLibFile ?? throw new ArgumentNullException(nameof(tagLibFile)); 16 | } 17 | 18 | public sealed override void Save( List messages ) 19 | { 20 | this.TagLibFile.Save(); 21 | SaveRecoveryTagToJsonFile( this.FileInfo, this.RecoveryTag, messages ); 22 | } 23 | 24 | protected sealed override void Dispose( Boolean disposing ) // Sealed Dispose method... ugly. 25 | { 26 | if( disposing ) 27 | { 28 | this.TagLibFile.Dispose(); 29 | } 30 | } 31 | 32 | public TFile TagLibFile { get; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /TeslaTags/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace TeslaTags 8 | { 9 | public static class Program 10 | { 11 | public static async Task Main(String[] args) 12 | { 13 | if( args.Length < 1 ) 14 | { 15 | Console.WriteLine( "Usage: TeslaTags.exe [ = false]" ); 16 | return 1; 17 | } 18 | 19 | String root = args[0]; 20 | Boolean readOnly = Boolean.Parse( args.ElementAtOrDefault(1) ?? "false" ); // `Boolean.Parse` is case-insensitive 21 | 22 | FileSystemPredicate fsp = new FileSystemPredicate( 23 | directoryPredicate: null, 24 | caseInsensitiveFileExtensions: FileSystemPredicate.DefaultAudioFileExtensions 25 | ); 26 | 27 | RetaggingOptions opts = new RetaggingOptions( root, readOnly, undo: false, fsp, genreRules: new GenreRules() ); 28 | 29 | RealTeslaTagService service = new RealTeslaTagService(); 30 | 31 | Progress> directoriesReceiver = new Progress>( directories => { 32 | 33 | if( directories.Count == 0 ) 34 | { 35 | Console.WriteLine( "Directory {0} is empty.", root ); 36 | } 37 | 38 | } ); 39 | 40 | Progress directoryReceiver = new Progress( result => { 41 | 42 | Console.Write( result.FolderType ); 43 | Console.Write( "{0} files, {1} modified files, ", result.TotalFiles, result.ActualModifiedFiles ); 44 | 45 | if( result.Messages.Any( m => m.Severity == MessageSeverity.Error ) ) Console.ForegroundColor = ConsoleColor.Red; 46 | Console.Write( "{0} errors", result.Messages.Count( m => m.Severity == MessageSeverity.Error ) ); 47 | Console.ResetColor(); 48 | 49 | Console.Write( ", " ); 50 | 51 | if( result.Messages.Any( m => m.Severity == MessageSeverity.Warning ) ) Console.ForegroundColor = ConsoleColor.Yellow; 52 | Console.Write( "{0} warnings", result.Messages.Count( m => m.Severity == MessageSeverity.Warning ) ); 53 | Console.ResetColor(); 54 | 55 | Console.WriteLine(); 56 | 57 | } ); 58 | 59 | CancellationTokenSource cts = new CancellationTokenSource(); 60 | 61 | try 62 | { 63 | Task task = service.StartRetaggingAsync( opts, directoriesReceiver, directoryReceiver, cts.Token ); 64 | await task; 65 | } 66 | catch( Exception ex ) 67 | { 68 | Console.ForegroundColor = ConsoleColor.Red; 69 | Console.WriteLine( "Error:" ); 70 | Console.ResetColor(); 71 | 72 | while( ex != null ) 73 | { 74 | Console.WriteLine( ex.GetType().FullName ); 75 | Console.WriteLine( ex.Message ); 76 | Console.WriteLine( ex.StackTrace ); 77 | Console.WriteLine(); 78 | 79 | ex = ex.InnerException; 80 | } 81 | } 82 | 83 | return 0; 84 | } 85 | } 86 | 87 | 88 | 89 | 90 | 91 | 92 | } 93 | -------------------------------------------------------------------------------- /TeslaTags/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle( "TeslaTags" )] 9 | [assembly: AssemblyDescription( "" )] 10 | [assembly: AssemblyConfiguration( "" )] 11 | [assembly: AssemblyCompany( "" )] 12 | [assembly: AssemblyProduct( "TeslaTags" )] 13 | [assembly: AssemblyCopyright( "Copyright © 2018" )] 14 | [assembly: AssemblyTrademark( "" )] 15 | [assembly: AssemblyCulture( "" )] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible( false )] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid( "831fe5d4-250f-4f55-9934-5072f2b7cbee" )] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion( "1.0.*" )] 36 | //[assembly: AssemblyFileVersion( "1.0.0.0" )] 37 | -------------------------------------------------------------------------------- /TeslaTags/Services/IDirectoryPredicate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | namespace TeslaTags 7 | { 8 | public class FileSystemPredicate 9 | { 10 | public static IReadOnlyList DefaultAudioFileExtensions { get; } = new List() 11 | { 12 | // https://github.com/Jehoel/TeslaTags/issues/6 <-- lists the file-types supported by Tesla's MCU. 13 | 14 | // MP3: 15 | ".mp3", 16 | ".mpeg3", 17 | 18 | // RIFF Wave: 19 | ".wav", 20 | ".wave", 21 | 22 | // MP4 + AAC 23 | ".aac", 24 | ".mp4", 25 | ".m4a", 26 | 27 | // OGG: 28 | ".ogg", 29 | 30 | // FLAC: 31 | ".flac", 32 | 33 | // AIFF: 34 | ".aiff", 35 | }; 36 | 37 | public static IReadOnlyList DefaultAlbumartImageFileExtensions { get; } = new List() 38 | { 39 | ".jpeg", 40 | ".jpg", 41 | ".png", 42 | ".bmp", // I don't think anyone should use an uncompressed raster BMP as album art, fwiw. 43 | ".gif" 44 | }; 45 | 46 | public static HashSet CreateFileExtensionHashSet( IEnumerable fileNameExtensions ) 47 | { 48 | IEnumerable exts = ( fileNameExtensions ?? Array.Empty() ) 49 | .Select( ext => ext.Trim() ) 50 | .Select( ext => ext.Trim( '*' ) ) 51 | .Where( ext => !String.IsNullOrWhiteSpace( ext ) ) // No need to do .Distinct() as it's a HashSet. 52 | .Select( ext => ext.StartsWith( ".", StringComparison.Ordinal ) ? ext : ( "." + ext ) ); 53 | 54 | return new HashSet( exts, StringComparer.OrdinalIgnoreCase ); 55 | } 56 | 57 | public static IReadOnlyList DefaultExcludeFolders { get; } = new List() 58 | { 59 | "iTunes", 60 | "License Backup" 61 | }; 62 | 63 | public FileSystemPredicate( IDirectoryPredicate directoryPredicate, IEnumerable caseInsensitiveFileExtensions ) 64 | { 65 | this.Directories = directoryPredicate ?? new EmptyDirectoryPredicate(); 66 | 67 | this.FileExtensionsToLoad = CreateFileExtensionHashSet( caseInsensitiveFileExtensions ); 68 | } 69 | 70 | public IDirectoryPredicate Directories { get; } 71 | 72 | public HashSet FileExtensionsToLoad { get; } 73 | } 74 | 75 | public interface IDirectoryPredicate 76 | { 77 | /// Indicates if the specified matches some condition. 78 | /// The root directory of a directory search is provided so that if any exclusion criteria match a full path, but only because it's in the root, then the rule won't apply (otherwise every directory would be excluded). E.g. If the root is C:\Users\Dave\Music and you want to exclude Dave Matthews Band, then excluding "dave" would exclude all directories. 79 | /// This directory is guaranteed to be a subdirectory of . 80 | Boolean Matches( DirectoryInfo root, DirectoryInfo directory ); 81 | } 82 | 83 | public class EmptyDirectoryPredicate : IDirectoryPredicate 84 | { 85 | public Boolean Matches( DirectoryInfo root, DirectoryInfo directory ) 86 | { 87 | return false; 88 | } 89 | } 90 | 91 | public class SubstringDirectoryPredicate : IDirectoryPredicate 92 | { 93 | private readonly IReadOnlyList substrings; 94 | private readonly StringComparison comparison; 95 | 96 | // Accepts an `IEnumerable` and makes a private copy because a mutable list might be passed-in. Can't accept `IReadOnlyList` because that might still be mutable. `IImmutableList` is not in the BCL but is in an extension: System.Collections.Immutable. Boo! 97 | public SubstringDirectoryPredicate( IEnumerable substrings, StringComparison comparison = StringComparison.OrdinalIgnoreCase ) 98 | { 99 | this.substrings = substrings == null ? (IReadOnlyList)Array.Empty() : substrings.ToList(); 100 | this.comparison = comparison; 101 | } 102 | 103 | public Boolean Matches( DirectoryInfo root, DirectoryInfo directory ) 104 | { 105 | if( root == null ) throw new ArgumentNullException(nameof(root)); 106 | if( directory == null ) throw new ArgumentNullException(nameof(directory)); 107 | 108 | // 109 | 110 | String rootRelativePath = directory.FullName.Substring( startIndex: root.FullName.Length ); 111 | 112 | return this.substrings.Any( ss => rootRelativePath.IndexOf( ss, this.comparison ) > -1 ); 113 | } 114 | } 115 | 116 | public class ExactPathComponentMatchPredicate : IDirectoryPredicate 117 | { 118 | private readonly HashSet pathComponents; 119 | 120 | public ExactPathComponentMatchPredicate( IEnumerable pathComponents ) 121 | { 122 | if( pathComponents == null ) pathComponents = Array.Empty(); 123 | this.pathComponents = new HashSet( pathComponents, StringComparer.OrdinalIgnoreCase ); // Case-insensitive on Windows. 124 | } 125 | 126 | public Boolean Matches( DirectoryInfo root, DirectoryInfo directory ) 127 | { 128 | if( root == null ) throw new ArgumentNullException(nameof(root)); 129 | if( directory == null ) throw new ArgumentNullException(nameof(directory)); 130 | 131 | // 132 | 133 | DirectoryInfo d = directory; 134 | while( d != null && d.FullName.Length > root.FullName.Length ) 135 | { 136 | if( this.pathComponents.Contains( d.Name ) ) 137 | { 138 | return true; 139 | } 140 | 141 | d = d.Parent; 142 | } 143 | 144 | return false; 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /TeslaTags/Services/ITeslaTagService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace TeslaTags 7 | { 8 | public interface ITeslaTagsService 9 | { 10 | Task StartRetaggingAsync( RetaggingOptions options, IProgress> directories, IProgress directoryProgress, CancellationToken cancellationToken ); 11 | 12 | Task> SetTrackNumbersFromFileNamesAsync(String directoryPath, HashSet fileExtensionsToLoad, Int32 offset, Int32? discNumber); 13 | 14 | Task> RemoveApeTagsAsync(String directoryPath, HashSet fileExtensionsToLoad); 15 | 16 | Task> SetAlbumArtAsync(String directoryPath, HashSet fileExtensionsToLoad, String imageFileName, AlbumArtSetMode mode); 17 | } 18 | 19 | public enum AlbumArtSetMode 20 | { 21 | Replace, 22 | AddIfMissing 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /TeslaTags/Services/Pocos/DirectoryResult.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeslaTags 5 | { 6 | public class DirectoryResult 7 | { 8 | private static readonly List _empty = new List(); 9 | 10 | public DirectoryResult( String directoryPath, FolderType folderType, Int32 totalFiles, Int32 proposedModifiedFilesCount, Int32 actualModifiedFilesCount, List messages ) 11 | { 12 | this.DirectoryPath = directoryPath; 13 | this.FolderType = folderType; 14 | this.TotalFiles = totalFiles; 15 | this.ProposedModifiedFiles = proposedModifiedFilesCount; 16 | this.ActualModifiedFiles = actualModifiedFilesCount; 17 | this.Messages = messages ?? _empty; 18 | } 19 | 20 | public String DirectoryPath { get; } 21 | public FolderType FolderType { get; } 22 | public Int32 TotalFiles { get; } 23 | public Int32 ProposedModifiedFiles { get; } 24 | public Int32 ActualModifiedFiles { get; } 25 | public IReadOnlyList Messages { get; } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TeslaTags/Services/Pocos/Message.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeslaTags 4 | { 5 | public class Message 6 | { 7 | public Message(MessageSeverity severity, String directory, String path, String text) 8 | { 9 | this.Severity = severity; 10 | this.FullPath = path ?? throw new ArgumentNullException(nameof(path)); 11 | this.IsDirectory = String.Equals( directory, path, StringComparison.OrdinalIgnoreCase ); 12 | this.RelativePath = path.StartsWith( directory, StringComparison.OrdinalIgnoreCase ) ? path.Substring( directory.Length ) : path; 13 | this.Text = text ?? throw new ArgumentNullException(nameof(text)); 14 | } 15 | 16 | public MessageSeverity Severity { get; } 17 | public String FullPath { get; } 18 | public String RelativePath { get; } 19 | public Boolean IsDirectory { get; } 20 | public String Text { get; } 21 | 22 | private String toString; 23 | 24 | public override String ToString() 25 | { 26 | return this.toString ?? ( this.toString = String.Concat( this.FullPath, "\t", this.Severity.ToString(), "\t", this.Text ) ); 27 | } 28 | } 29 | 30 | public enum MessageSeverity 31 | { 32 | Info, 33 | FileModification, 34 | Warning, 35 | Error 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /TeslaTags/Services/Pocos/MessageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | 7 | namespace TeslaTags 8 | { 9 | public static partial class MessageExtensions 10 | { 11 | public static void AddInfoFile( this List messages, String filePath, String text ) 12 | { 13 | messages.Add( new Message( MessageSeverity.Info, Path.GetDirectoryName( filePath ), filePath, text ) ); 14 | } 15 | 16 | public static void AddInfoDirectory( this List messages, String directoryPath, String text ) 17 | { 18 | messages.Add( new Message( MessageSeverity.Info, directoryPath, directoryPath, text ) ); 19 | } 20 | 21 | /// Returns false if there were no reasons. 22 | public static Boolean AddFileCorruptionErrors( this List messages, String filePath, IEnumerable corruptionReasons ) 23 | { 24 | if( corruptionReasons == null ) return false; 25 | 26 | Boolean any = false; 27 | 28 | foreach( String reason in corruptionReasons ) 29 | { 30 | messages.AddFileError( filePath, "File corrupted: " + reason ); 31 | any = true; 32 | } 33 | 34 | return any; 35 | } 36 | 37 | public static void AddFileWarning( this List messages, String filePath, String text ) 38 | { 39 | messages.Add( new Message( MessageSeverity.Warning, Path.GetDirectoryName( filePath ), filePath, text ) ); 40 | } 41 | 42 | public static void AddFileWarning( this List messages, String filePath, String format, params Object[] args ) 43 | { 44 | MessageExtensions.AddFileWarning( messages, filePath, text: String.Format( CultureInfo.InvariantCulture, format, args ) ); 45 | } 46 | 47 | public static void AddFileError( this List messages, String filePath, String text ) 48 | { 49 | messages.Add( new Message( MessageSeverity.Error, Path.GetDirectoryName( filePath ), filePath, text ) ); 50 | } 51 | 52 | public static void AddFileError( this List messages, String filePath, String format, params Object[] args ) 53 | { 54 | MessageExtensions.AddFileError( messages, filePath, text: String.Format( CultureInfo.InvariantCulture, format, args ) ); 55 | } 56 | 57 | public static void AddFileChange( this List messages, String filePath, String messageText ) 58 | { 59 | messages.Add( new Message( MessageSeverity.FileModification, Path.GetDirectoryName( filePath ), filePath, messageText ) ); 60 | } 61 | 62 | public static void AddFileChange( this List messages, String filePath, String field, String oldValue, String newValue ) 63 | { 64 | oldValue = ( oldValue == null ) ? "null" : ("\"" + oldValue + "\""); 65 | newValue = ( newValue == null ) ? "null" : ("\"" + newValue + "\""); 66 | 67 | String messageText = String.Concat( field, ": ", oldValue, " -> ", newValue ); 68 | 69 | messages.Add( new Message( MessageSeverity.FileModification, Path.GetDirectoryName( filePath ), filePath, messageText ) ); 70 | } 71 | 72 | public static Int32 GetModifiedFileCount( this IEnumerable messages ) 73 | { 74 | if( messages == null ) throw new ArgumentNullException(nameof(messages)); 75 | 76 | Int32 modifiedFileCount = messages 77 | .GroupBy( m => m.FullPath ) 78 | .Where( grp => grp.Any( m => m.Severity == MessageSeverity.FileModification ) ) 79 | .Count(); 80 | 81 | return modifiedFileCount; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /TeslaTags/Services/RealTeslaTagService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | namespace TeslaTags 11 | { 12 | public partial class RealTeslaTagService : ITeslaTagsService 13 | { 14 | private sealed class LogFiles : IDisposable 15 | { 16 | const String DirectoryLog = "TeslaTags_DoneDirectories.txt"; 17 | const String GeneralLog = "TeslaTags_Log.txt"; 18 | 19 | public static LogFiles Create(String rootDirectory) 20 | { 21 | String directoryLogFileName = Path.Combine( rootDirectory, DirectoryLog ); 22 | String generalLogFileName = Path.Combine( rootDirectory, GeneralLog ); 23 | 24 | HashSet doneDirectories = new HashSet( StringComparer.OrdinalIgnoreCase ); 25 | 26 | if( File.Exists( directoryLogFileName ) && new FileInfo( directoryLogFileName ).Length > 3 ) // 3 for the BOM 27 | { 28 | foreach( String line in File.ReadAllLines( directoryLogFileName ) ) 29 | { 30 | doneDirectories.Add( line ); 31 | } 32 | } 33 | 34 | StreamWriter directoryLogWriter = null; 35 | StreamWriter generalLogWriter = null; 36 | try 37 | { 38 | directoryLogWriter = new StreamWriter( directoryLogFileName, append: true ); 39 | generalLogWriter = new StreamWriter( generalLogFileName , append: true ); 40 | 41 | return new LogFiles( directoryLogWriter, generalLogWriter, directoryLogFileName, generalLogFileName, doneDirectories ); 42 | } 43 | catch 44 | { 45 | directoryLogWriter?.Dispose(); 46 | generalLogWriter ?.Dispose(); 47 | 48 | throw; 49 | } 50 | } 51 | 52 | private LogFiles(StreamWriter directoryLogWriter, StreamWriter generalLogWriter, String directoryLogFileName, String generalLogFileName, HashSet doneDirectories) 53 | { 54 | this.directoryLogWriter = directoryLogWriter ?? throw new ArgumentNullException( nameof( directoryLogWriter ) ); 55 | this.generalLogWriter = generalLogWriter ?? throw new ArgumentNullException( nameof( generalLogWriter ) ); 56 | this.directoryLogFileName = directoryLogFileName ?? throw new ArgumentNullException( nameof( directoryLogFileName ) ); 57 | this.generalLogFileName = generalLogFileName ?? throw new ArgumentNullException( nameof( generalLogFileName ) ); 58 | this.doneDirectories = doneDirectories ?? throw new ArgumentNullException( nameof( doneDirectories ) ); 59 | } 60 | 61 | public void Dispose() 62 | { 63 | this.directoryLogWriter.Dispose(); 64 | this.generalLogWriter .Dispose(); 65 | } 66 | 67 | public void DeleteDirectoryLog() 68 | { 69 | // Close the streams and delete the log files. 70 | this.directoryLogWriter.Dispose(); 71 | if( File.Exists( this.directoryLogFileName ) ) File.Delete( this.directoryLogFileName ); 72 | } 73 | 74 | private readonly StreamWriter directoryLogWriter; 75 | private readonly StreamWriter generalLogWriter; 76 | 77 | public readonly String directoryLogFileName; 78 | public readonly String generalLogFileName; 79 | 80 | public readonly HashSet doneDirectories; 81 | 82 | public void DirectoryLogWriteLine( String line ) 83 | { 84 | this.directoryLogWriter.WriteLine( line ); 85 | this.directoryLogWriter.Flush(); 86 | } 87 | 88 | public void GeneralLogWriteLine( String line ) 89 | { 90 | this.generalLogWriter.WriteLine( line ); 91 | this.generalLogWriter.Flush(); 92 | } 93 | 94 | public void GeneralLogWriteLines( IEnumerable lines ) 95 | { 96 | foreach( String line in lines ) 97 | { 98 | this.generalLogWriter.WriteLine( line ); 99 | } 100 | 101 | this.generalLogWriter.Flush(); 102 | } 103 | } 104 | 105 | public Task StartRetaggingAsync( RetaggingOptions options, IProgress> directoriesProgress, IProgress directoryProgress, CancellationToken cancellationToken ) 106 | { 107 | TaskCompletionSource tcs = new TaskCompletionSource(); 108 | 109 | ThreadPool.QueueUserWorkItem( new WaitCallback( ( state ) => { 110 | 111 | try 112 | { 113 | RunRetagging( options, directoriesProgress, directoryProgress, cancellationToken ); 114 | 115 | if( cancellationToken.IsCancellationRequested ) 116 | { 117 | tcs.SetCanceled(); 118 | } 119 | else 120 | { 121 | tcs.SetResult( null ); // Mark the `tcs.Task` as completed successfully. 122 | } 123 | } 124 | catch( Exception ex ) 125 | { 126 | tcs.SetException( ex ); 127 | } 128 | } ) ); 129 | 130 | return tcs.Task; 131 | 132 | //return Task.Run( () => RunRetagging( musicRootDirectory, readOnly, undo, genreRules, listener ) ); 133 | } 134 | 135 | public static void RunRetagging( RetaggingOptions options, IProgress> directoriesProgress, IProgress directoryProgress, CancellationToken cancellationToken ) 136 | { 137 | using( LogFiles logs = LogFiles.Create( options.MusicRootDirectory ) ) 138 | { 139 | try 140 | { 141 | RunRetaggingInner( logs, options, directoriesProgress, directoryProgress, cancellationToken ); 142 | } 143 | catch( Exception ex ) 144 | { 145 | logs.GeneralLogWriteLine( "Unhandled {0}: {1} at {2:yyyy-MM-dd HH:mm:ss} UTC\r\n".FormatInvariant( ex.GetType().Name, ex.Message, DateTime.UtcNow ) ); 146 | throw; 147 | } 148 | } 149 | } 150 | 151 | private static void RunRetaggingInner( LogFiles logs, RetaggingOptions options, IProgress> directoriesProgress, IProgress directoryProgress, CancellationToken cancellationToken ) 152 | { 153 | Stopwatch sw = Stopwatch.StartNew(); 154 | 155 | logs.GeneralLogWriteLine( "Started at {0:yyyy-MM-dd HH:mm:ss} UTC\r\n".FormatInvariant( DateTime.UtcNow ) ); 156 | 157 | List directories = GetDirectories( options.FileSystemPredicate.Directories, options.MusicRootDirectory ); 158 | 159 | logs.GeneralLogWriteLine( "Enumerated directories after {0:N2}ms\r\n".FormatInvariant( sw.ElapsedMilliseconds ) ); 160 | 161 | directoriesProgress.Report( directories ); 162 | 163 | Int32 i = 0; 164 | foreach( String directoryPath in directories ) 165 | { 166 | if( cancellationToken.IsCancellationRequested ) break; 167 | 168 | DirectoryResult result = ProcessDirectory( directoryPath, options.FileSystemPredicate.FileExtensionsToLoad, options.ReadOnly, options.Undo, options.GenreRules, logs ); 169 | 170 | directoryProgress.Report( result ); 171 | i++; 172 | } 173 | 174 | if( cancellationToken.IsCancellationRequested ) 175 | { 176 | logs.GeneralLogWriteLine( "Cancelled after processing {0} directories at {1:yyyy-MM-dd HH:mm:ss} UTC\r\n".FormatInvariant( i, DateTime.UtcNow ) ); 177 | } 178 | else 179 | { 180 | logs.DeleteDirectoryLog(); 181 | logs.GeneralLogWriteLine( "Completed processing {0} directories successfully at {1:yyyy-MM-dd HH:mm:ss} UTC\r\n".FormatInvariant( i, DateTime.UtcNow ) ); 182 | } 183 | } 184 | 185 | public static List GetDirectories( IDirectoryPredicate excludePredicate, String root ) 186 | { 187 | // Directory.EnumerateDirectories returns strings without a trailing slash. 188 | 189 | DirectoryInfo rootDir = new DirectoryInfo( root ); 190 | 191 | List list = new List(); 192 | list.Add( root ); 193 | list.AddRange( 194 | Directory 195 | .EnumerateDirectories( root, "*", SearchOption.AllDirectories /* AllDirectories == include all descendant directories and reparse points */ ) 196 | .Where( s => s != null ) // I don't know why I was seeing nulls... was I? 197 | .Where( directoryPath => !excludePredicate.Matches( rootDir, new DirectoryInfo( directoryPath ) ) ) 198 | ); 199 | list.Sort(); 200 | 201 | return list; 202 | } 203 | 204 | /// If true, then warnings and errors will be generated, but files will not be modified. 205 | private static DirectoryResult ProcessDirectory( String directoryPath, HashSet fileExtensionsToLoad, Boolean readOnly, Boolean undo, GenreRules genreRules, LogFiles logs ) 206 | { 207 | List messages = new List(); 208 | 209 | if( logs.doneDirectories.Contains( directoryPath ) ) 210 | { 211 | String text = "Directory \"" + directoryPath + "\" is listed in already-done log: \"" + logs.directoryLogFileName + "\" so it was skipped. Delete the log file (or remove the directory's entry) to process this directory again."; 212 | messages.AddInfoDirectory( directoryPath, text ); 213 | 214 | return new DirectoryResult( directoryPath, FolderType.Skipped, 0, 0, 0, messages ); 215 | } 216 | 217 | (FolderType folderType, Int32 modifiedCountProposed, Int32 modifiedCountActual, Int32 totalCount) = TeslaTagFolderProcessor.Process( directoryPath, fileExtensionsToLoad, readOnly, undo, genreRules, messages ); 218 | 219 | try 220 | { 221 | logs.GeneralLogWriteLines( messages.Select( msg => msg.ToString() ) ); 222 | } 223 | catch( Exception ex ) 224 | { 225 | String text = String.Format( CultureInfo.InvariantCulture, @"Couldn't write to log file ""{0}"". Exception: {1}, Message: ""{2}"".", logs.generalLogFileName, ex.GetType().Name, ex.Message ); 226 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, text ) ); 227 | } 228 | 229 | try 230 | { 231 | logs.DirectoryLogWriteLine( directoryPath ); 232 | } 233 | catch( Exception ex ) 234 | { 235 | String text = String.Format( CultureInfo.InvariantCulture, @"Couldn't write to log file ""{0}"". Exception: {1}, Message: ""{2}"".", logs.directoryLogFileName, ex.GetType().Name, ex.Message ); 236 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, text ) ); 237 | } 238 | 239 | return new DirectoryResult( directoryPath, folderType, totalCount, modifiedCountProposed, modifiedCountActual, messages ); 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /TeslaTags/Services/RealTeslaTagUtilityService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | 8 | using TagLib; 9 | 10 | using ape = TagLib.Ape; 11 | 12 | namespace TeslaTags 13 | { 14 | public partial class RealTeslaTagService 15 | { 16 | private static List Wrap( String directoryPath, HashSet fileExtensionsToLoad, Action,List> action ) 17 | { 18 | List messages = new List(); 19 | List files = TeslaTagFolderProcessor.LoadFiles( directoryPath, fileExtensionsToLoad, messages ); 20 | try 21 | { 22 | action( directoryPath, files, messages ); 23 | return messages; 24 | } 25 | finally 26 | { 27 | foreach( LoadedFile file in files ) 28 | { 29 | if( file.IsModified ) file.Save( messages ); 30 | file.Dispose(); 31 | } 32 | } 33 | } 34 | 35 | public Task> RemoveApeTagsAsync( String directoryPath, HashSet fileExtensionsToLoad ) 36 | { 37 | return Task.Run( () => Wrap( directoryPath, fileExtensionsToLoad, RemoveApeTagsInner ) ); 38 | } 39 | 40 | private static void RemoveApeTagsInner( String directoryPath, List files, List messages ) 41 | { 42 | foreach( MpegLoadedFile mpegFile in files.OfType() ) 43 | { 44 | ape.Tag apeTag = (ape.Tag)mpegFile.MpegAudioFile.GetTag(TagTypes.Ape); 45 | if( apeTag != null ) 46 | { 47 | mpegFile.MpegAudioFile.RemoveTags( TagTypes.Ape ); 48 | mpegFile.IsModified = true; 49 | messages.AddFileChange( mpegFile.FileInfo.FullName, "APE tag removed." ); 50 | } 51 | } 52 | } 53 | 54 | public Task> SetAlbumArtAsync( String directoryPath, HashSet fileExtensionsToLoad, String imageFileName, AlbumArtSetMode mode ) 55 | { 56 | if( String.IsNullOrWhiteSpace( directoryPath ) || !Directory.Exists( directoryPath ) ) throw new ArgumentException( "Value must be a valid path to a directory that exists.", nameof(directoryPath) ); 57 | if( String.IsNullOrWhiteSpace( imageFileName ) ) throw new ArgumentNullException( nameof(imageFileName) ); 58 | imageFileName = Path.IsPathRooted( imageFileName ) ? imageFileName : Path.Combine( directoryPath, imageFileName ); 59 | if( !System.IO.File.Exists( imageFileName ) ) throw new ArgumentException( "Value must be a file that exists.", nameof(imageFileName ) ); 60 | 61 | return Task.Run( () => Wrap( directoryPath, fileExtensionsToLoad, (dp, files, messages) => SetAlbumArtInner( files, messages, imageFileName, mode ) ) ); 62 | } 63 | 64 | private static void SetAlbumArtInner(List files, List messages, String imageFileName, AlbumArtSetMode mode) 65 | { 66 | // imageFileName will be resolved by now. 67 | 68 | IPicture newPicture = new Picture( imageFileName ); 69 | 70 | foreach( LoadedFile file in files ) 71 | { 72 | IPicture[] pictures = file.Tag.Pictures; 73 | if( pictures == null || pictures.Length == 0 ) 74 | { 75 | pictures = new IPicture[1]; 76 | pictures[0] = newPicture; 77 | 78 | file.Tag.Pictures = pictures; 79 | file.IsModified = true; 80 | 81 | messages.AddFileChange( file.FileInfo.FullName, "Had zero pictures. Added new picture." ); 82 | } 83 | else 84 | { 85 | Int32 oldPictureCount = pictures.Length; 86 | List existingIdenticalPictures = pictures 87 | .Where( p => p.Data.CompareTo( newPicture.Data ) == 0 ) 88 | .ToList(); 89 | 90 | if( existingIdenticalPictures.Count > 1 ) 91 | { 92 | messages.AddFileWarning( file.FileInfo.FullName, "Duplicate pictures in file." ); 93 | } 94 | else if( existingIdenticalPictures.Count == 1 ) 95 | { 96 | messages.AddInfoFile( file.FileInfo.FullName, "New picture already exists in file." ); 97 | } 98 | 99 | switch( mode ) 100 | { 101 | case AlbumArtSetMode.AddIfMissing: 102 | 103 | if( existingIdenticalPictures.Count == 0 ) 104 | { 105 | Array.Resize( ref pictures, pictures.Length + 1 ); 106 | pictures[ pictures.GetUpperBound(0) ] = newPicture; 107 | 108 | file.Tag.Pictures = pictures; 109 | file.IsModified = true; 110 | 111 | messages.AddFileChange( file.FileInfo.FullName, "Added new picture to list of existing pictures. File now has " + pictures.Length + " pictures." ); 112 | } 113 | 114 | break; 115 | 116 | case AlbumArtSetMode.Replace: 117 | 118 | pictures = new IPicture[1]; 119 | pictures[0] = newPicture; 120 | 121 | file.Tag.Pictures = pictures; 122 | file.IsModified = true; 123 | 124 | messages.AddFileChange( file.FileInfo.FullName, "Replaced " + oldPictureCount + " pictures with single new picture." ); 125 | 126 | break; 127 | } 128 | } 129 | } 130 | } 131 | 132 | public Task> SetTrackNumbersFromFileNamesAsync( String directoryPath, HashSet fileExtensionsToLoad, Int32 offset, Int32? discNumber ) 133 | { 134 | if( String.IsNullOrWhiteSpace( directoryPath ) || !Directory.Exists( directoryPath ) ) throw new ArgumentException( "Value must be a valid path to a directory that exists.", nameof(directoryPath) ); 135 | 136 | return Task.Run( () => Wrap( directoryPath, fileExtensionsToLoad, (dp, files, messages) => SetTrackNumbersFromFileNamesInner( dp, files, messages, offset, discNumber ) ) ); 137 | } 138 | 139 | private static void SetTrackNumbersFromFileNamesInner(String directoryPath, List files, List messages, Int32 offset, Int32? discNumber) 140 | { 141 | var result = DiscAndTrackNumberHelper.GetDiscTrackNumberForAllFiles( directoryPath ); 142 | if( !result.hasBest ) 143 | { 144 | messages.Add( new Message(MessageSeverity.Error, directoryPath, directoryPath, "Could not find a file-name pattern for at least half the files in the directory. Aborting." ) ); 145 | return; 146 | } 147 | 148 | foreach( LoadedFile file in files ) 149 | { 150 | var (disc, track, err) = result.files[ file.FileInfo.Name ]; 151 | if( err != null ) 152 | { 153 | messages.AddFileError( file.FileInfo.FullName, err ); 154 | continue; 155 | } 156 | 157 | if( discNumber != null ) disc = discNumber.Value; 158 | 159 | if( disc != null ) 160 | { 161 | String oldDisc = file.Tag.Disc.ToString(CultureInfo.InvariantCulture); 162 | UInt32 newDisc = (UInt32)disc.Value; 163 | 164 | file.Tag.Disc = newDisc; 165 | file.IsModified = true; 166 | 167 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Disc), oldDisc, newDisc.ToString(CultureInfo.InvariantCulture) ); 168 | } 169 | 170 | if( track != null ) 171 | { 172 | Int32 newTrack = Math.Max( 0, track.Value + offset ); 173 | 174 | String oldTrack = file.Tag.Track.ToString(CultureInfo.InvariantCulture); 175 | 176 | file.Tag.Track = (UInt32)newTrack; 177 | file.IsModified = true; 178 | 179 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Track), oldTrack, newTrack.ToString(CultureInfo.InvariantCulture) ); 180 | } 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/DiscAndTrackNumberHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace TeslaTags 9 | { 10 | public static class DiscAndTrackNumberHelper 11 | { 12 | private static readonly Regex _fileName_DiscNumberRegex = new Regex( @"\bdisc\D{0,3}(\d{1,3})", RegexOptions.Compiled | RegexOptions.IgnoreCase ); 13 | 14 | public static (Boolean hasBest,Dictionary files) GetDiscTrackNumberForAllFiles(String directoryPath) 15 | { 16 | DirectoryInfo directoryInfo = new DirectoryInfo( directoryPath ); 17 | 18 | Match parentDirectoryDiscNumberMatch = _fileName_DiscNumberRegex.Match( directoryInfo.Name ); 19 | 20 | Int32? discNumberFromParentDirectory = null; 21 | if( parentDirectoryDiscNumberMatch.Success && parentDirectoryDiscNumberMatch.Groups.Count >= 1 ) 22 | { 23 | discNumberFromParentDirectory = Int32.Parse( parentDirectoryDiscNumberMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 24 | } 25 | 26 | FileInfo[] files = directoryInfo.GetFiles(); 27 | 28 | Regex bestRegex = GetBestDiscTrackNumberRegexForDirectory( directoryPath ); 29 | if( bestRegex == null ) 30 | { 31 | var dict = files 32 | .ToDictionary( 33 | fi => fi.Name, 34 | fi => GetDiscTrackNumberFromFileNameOnly( fi.FullName ) 35 | ); 36 | 37 | return ( hasBest: false, dict ); 38 | } 39 | else 40 | { 41 | var dict = files 42 | .ToDictionary( 43 | fi => fi.Name, 44 | fi => 45 | { 46 | Match match = bestRegex.Match( Path.GetFileNameWithoutExtension( fi.Name ) ); 47 | var tuple = GetDiscTrackNumberFromMatch( match ); 48 | return tuple; 49 | } 50 | ); 51 | 52 | return ( hasBest: true, dict ); 53 | } 54 | } 55 | 56 | public static (Int32? disc, Int32? track, String err) GetDiscTrackNumberFromFileName(String fileName, Boolean checkSiblings) 57 | { 58 | // 1. If there's no disc information in the parent folder path (only look at the parent directory, not other ancestors): 59 | FileInfo fileInfo = new FileInfo( fileName ); 60 | 61 | Match parentDirectoryDiscNumberMatch = _fileName_DiscNumberRegex.Match( fileInfo.Directory.Name ); 62 | if( parentDirectoryDiscNumberMatch.Success ) 63 | { 64 | Int32 discNumberFromParentDirectory = Int32.Parse( parentDirectoryDiscNumberMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 65 | 66 | // As disc information is in the directory, we must assert that it is either NOT in the filename itself, or the filename matches: 67 | 68 | Match fileNameDiscNumberMatch = _fileName_DiscNumberRegex.Match( fileInfo.Name ); 69 | if( fileNameDiscNumberMatch.Success ) 70 | { 71 | Int32 discNumberFromFileName = Int32.Parse( fileNameDiscNumberMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 72 | if( discNumberFromFileName != discNumberFromParentDirectory ) return ( null, null, "Different disc numbers in parent directory and filename." ); 73 | } 74 | 75 | (Int32? fnDisc, Int32? fnTrack, String fnErr ) = checkSiblings ? GetDiscTrackNumberFromFileNameAndSiblings( fileName ) : GetDiscTrackNumberFromFileNameOnly( fileName ); 76 | if( fnErr != null ) return ( null, null, fnErr ); 77 | 78 | if( fnDisc != null && discNumberFromParentDirectory != fnDisc.Value ) 79 | { 80 | return ( null, null, "Different disc numbers in parent directory and filename." ); 81 | } 82 | 83 | return ( discNumberFromParentDirectory, fnTrack, null ); 84 | } 85 | else 86 | { 87 | // 2. If not, just check the filename. 88 | return checkSiblings ? GetDiscTrackNumberFromFileNameAndSiblings( fileName ) : GetDiscTrackNumberFromFileNameOnly( fileName ); 89 | } 90 | } 91 | 92 | // These expressions assume the file extension (with leading dot) is not included, as that can include digits (e.g. ".mp3", ".mp4", etc) 93 | private static readonly Regex[] _fileNamePatterns = new String[] 94 | { 95 | // We can assume if there's 2 groups matched, then the first one will be the disc number. I can't think of any naming system where the disc number comes after the track number, or the track title coming after the track number. 96 | 97 | @"^(\d{1,3})\b", // File starts with 1 to 3 digits, presumably the track number (4 digits would be a year, I imagine. I wonder if 3 digits is too many and might conflate with a number in the band's name or album name if included in the filename) 98 | @"^(?:\D+)(\d{1,2})\b", // Non-digits, then 1-2 digits 99 | @"^(\d{1,2})(?:\D+)\b(\d{1,2})", // File starts with 1-2 digit, followed by non-digits (non-capturing group), then a word-boundary, then 1-2 digits again 100 | @"^(?:\D+)(\d{1,2})(?:\D+)\b(\d{1,2})", // Non-digits, then digits, then non-digits, then digits again. 101 | } 102 | .Select( s => new Regex( s, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline ) ) 103 | .ToArray(); 104 | 105 | public static Regex GetBestDiscTrackNumberRegexForDirectory(String directoryPath) 106 | { 107 | List fileNames = Directory 108 | .GetFiles( directoryPath ) 109 | .Select( fn => Path.GetFileNameWithoutExtension( fn ) ) 110 | .ToList(); 111 | 112 | if( fileNames.Count == 0 ) return null; 113 | 114 | Int32[] regexScores = new Int32[ _fileNamePatterns.Length ]; 115 | for( Int32 i = 0; i < _fileNamePatterns.Length; i++ ) 116 | { 117 | Int32 score = fileNames.Count( fn => _fileNamePatterns[i].IsMatch( fn ) ); 118 | regexScores[i] = score; 119 | } 120 | 121 | // Is there an outright winner? 122 | Regex maxScoreRegex = null; 123 | { 124 | // The winning regex must match at least half of the songs in the directory, and at least 1 match. 125 | Int32 maxScore = Math.Max( 1, fileNames.Count / 2 ); 126 | for( Int32 i = 0; i < regexScores.Length; i++ ) 127 | { 128 | // If the score is the same, then use the current regex, because higher _fileNamePattern indexes have higher specificity. 129 | if( regexScores[i] >= maxScore ) 130 | { 131 | maxScore = regexScores[i]; 132 | maxScoreRegex = _fileNamePatterns[i]; 133 | } 134 | } 135 | } 136 | 137 | return maxScoreRegex; 138 | } 139 | 140 | private static (Int32? disc, Int32? track, String err) GetDiscTrackNumberFromFileNameOnly(String fileName) 141 | { 142 | String fn = Path.GetFileNameWithoutExtension( fileName ); 143 | 144 | // Use the most-specific regex: 145 | for( Int32 i = _fileNamePatterns.GetUpperBound(0); i >= 0; i-- ) 146 | { 147 | Match match = _fileNamePatterns[i].Match( fn ); 148 | if( match.Success ) return GetDiscTrackNumberFromMatch( match ); 149 | } 150 | 151 | return ( null, null, "File name did not match any common pattern." ); 152 | } 153 | 154 | private static (Int32? disc, Int32? track, String err) GetDiscTrackNumberFromFileNameAndSiblings(String fileName) 155 | { 156 | // Because track filenames can contain extra digits, just as part of their name, it's best to see which regex matches the most of the file's siblings, so we're sure there's a good pattern to use. 157 | String fn = Path.GetFileNameWithoutExtension( fileName ); 158 | 159 | Regex mostCommonPatternInDirectory = GetBestDiscTrackNumberRegexForDirectory( Path.GetDirectoryName( fileName ) ); 160 | if( mostCommonPatternInDirectory == null ) 161 | { 162 | return ( null, null, "No pattern matches at least half of the files in the directory. Cannot confidently extract disc and track information from \"" + fn + "\"." ); 163 | } 164 | 165 | Match match = mostCommonPatternInDirectory.Match( fn ); 166 | if( !match.Success ) 167 | { 168 | // If this file bucks the trend, then it probably doesn't contain the track number in the filename, as a one-off or minority (e.g. those "assorted" tracks I often put in albums that they're related to, e.g. as B-sides or demo material). 169 | return ( null, null, "File does not match most commonly-used pattern in directory." ); 170 | } 171 | else 172 | { 173 | return GetDiscTrackNumberFromMatch( match ); 174 | } 175 | } 176 | 177 | private static (Int32? disc, Int32? track, String err) GetDiscTrackNumberFromMatch(Match match) 178 | { 179 | if( !match.Success ) 180 | { 181 | return ( null, null, "File name does not match pattern." ); 182 | } 183 | else if( match.Groups.Count == 2 ) // [0] = Input, [1] = Track 184 | { 185 | Int32 trackNumber = Int32.Parse( match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 186 | return ( null, trackNumber, null ); 187 | } 188 | else if( match.Groups.Count == 3 ) // [0] = Input, [1] = Disc, [2] = Track 189 | { 190 | Int32 discNumber = Int32.Parse( match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 191 | Int32 trackNumber = Int32.Parse( match.Groups[2].Value, NumberStyles.Integer, CultureInfo.InvariantCulture ); 192 | return ( discNumber, trackNumber, null ); 193 | } 194 | else 195 | { 196 | throw new Exception( "Unexpected pattern match results." ); 197 | } 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/GenreRules.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeslaTags 4 | { 5 | public class GenreRules 6 | { 7 | public AssortedFilesGenreAction AssortedFilesAction { get; set; } 8 | 9 | public GenreAction ArtistAlbumWithGuestArtistsAction { get; set; } 10 | 11 | public GenreAction ArtistAssortedAction { get; set; } 12 | 13 | public GenreAction ArtistAlbumAction { get; set; } 14 | 15 | public GenreAction CompilationAlbumAction { get; set; } 16 | 17 | public Boolean AlwaysNoop => 18 | this.AssortedFilesAction == AssortedFilesGenreAction.Preserve && 19 | this.ArtistAlbumWithGuestArtistsAction == GenreAction.Preserve && 20 | this.ArtistAssortedAction == GenreAction.Preserve && 21 | this.ArtistAlbumAction == GenreAction.Preserve && 22 | this.CompilationAlbumAction == GenreAction.Preserve; 23 | } 24 | 25 | public enum GenreAction 26 | { 27 | Preserve = 0, 28 | Clear = 1, 29 | UseArtist = 2 30 | } 31 | 32 | public enum AssortedFilesGenreAction 33 | { 34 | Preserve = 0, 35 | Clear = 1, 36 | UseArtist = 2, 37 | UseFolderName = 3 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/RecoveryTag.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeslaTags 4 | { 5 | public class RecoveryTag 6 | { 7 | public String Album { get; set; } 8 | public String Artist { get; set; } 9 | public String Genre { get; set; } 10 | public Int32? TrackNumber { get; set; } 11 | public String Title { get; set; } 12 | 13 | public Boolean IsEmpty => 14 | this.Album == null && 15 | this.Artist == null && 16 | this.Genre == null && 17 | this.TrackNumber == null && 18 | this.Title == null; 19 | 20 | public Boolean IsSet => !this.IsEmpty; 21 | 22 | public void Clear() 23 | { 24 | this.Album = null; 25 | this.Artist = null; 26 | this.Genre = null; 27 | this.TrackNumber = null; 28 | this.Title = null; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/RetaggingOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace TeslaTags 8 | { 9 | public class RetaggingOptions 10 | { 11 | public RetaggingOptions(String musicRootDirectory, Boolean readOnly, Boolean undo, FileSystemPredicate fsPredicate, GenreRules genreRules) 12 | { 13 | this.MusicRootDirectory = musicRootDirectory ?? throw new ArgumentNullException( nameof( musicRootDirectory ) ); 14 | this.ReadOnly = readOnly; 15 | this.Undo = undo; 16 | this.FileSystemPredicate = fsPredicate ?? throw new ArgumentNullException( nameof(fsPredicate) ); 17 | this.GenreRules = genreRules ?? throw new ArgumentNullException( nameof( genreRules ) ); 18 | } 19 | 20 | public String MusicRootDirectory { get; } 21 | public Boolean ReadOnly { get; } 22 | public Boolean Undo { get; } 23 | public FileSystemPredicate FileSystemPredicate { get; } 24 | public GenreRules GenreRules { get; } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/TagExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | using TagLib; 8 | 9 | namespace TeslaTags 10 | { 11 | public static class TagExtensions 12 | { 13 | // Workaround for the fact TagLib and ID3v2 split on '/' when that may be undesirable, e.g. "AC/DC". 14 | 15 | public static String GetPerformers( this Tag tag ) 16 | { 17 | if( tag == null ) throw new ArgumentNullException(nameof(tag)); 18 | 19 | return GetOriginalTagValue( tag, tag.Performers ); 20 | } 21 | 22 | public static String GetAlbumArtist( this Tag tag ) 23 | { 24 | if( tag == null ) throw new ArgumentNullException(nameof(tag)); 25 | 26 | return GetOriginalTagValue( tag, tag.AlbumArtists ); 27 | } 28 | 29 | private static String GetOriginalTagValue( Tag tag, String[] tagValue ) 30 | { 31 | // Note the property accessors always allocates a new array on every call, wow. 32 | 33 | if( tagValue.Length == 1 ) return tagValue[0]; 34 | 35 | if( tag is global::TagLib.Id3v2.Tag ) 36 | { 37 | // The ID3Tagv2 in TagLibSharp always splits on '/' for Performers, AlbumArtist, Conductor and Composer. 38 | // But does NOT split on Genre. 39 | // See `TagLib.Id3v2.TextInformationFrame.ParseRawData()` 40 | 41 | return String.Join( "/", tagValue ); 42 | } 43 | else if( tag is global::TagLib.Id3v1.Tag ) 44 | { 45 | return String.Join( ";", tagValue ); 46 | } 47 | else 48 | { 49 | // Uhhhh... what should the default fallback be? 50 | // I guess semi-colon separated too until we know better. 51 | return String.Join( ";", tagValue ); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/TagWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TeslaTags 5 | { 6 | public static class TagWriter 7 | { 8 | public static void SetAlbum( LoadedFile file, List messages, String newAlbum ) 9 | { 10 | String oldAlbum = file.Tag.Album; 11 | 12 | if( String.Equals( oldAlbum, newAlbum, StringComparison.Ordinal ) ) return; // NOOP, includes null 13 | 14 | file.Tag.Album = newAlbum; 15 | 16 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Album), oldAlbum, newAlbum ); 17 | file.IsModified = true; 18 | 19 | file.RecoveryTag.Album = oldAlbum; 20 | } 21 | 22 | public static void SetArtist( LoadedFile file, List messages, String newArtist ) 23 | { 24 | String oldArtist = file.Tag.GetPerformers(); 25 | 26 | if( String.Equals( oldArtist, newArtist, StringComparison.Ordinal ) ) return; // NOOP, includes null 27 | 28 | file.Tag.Performers = ( newArtist == null ) ? null : new String[] { newArtist }; 29 | 30 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Performers), oldArtist, newArtist ); 31 | file.IsModified = true; 32 | 33 | file.RecoveryTag.Artist = oldArtist; 34 | } 35 | 36 | public static void SetGenre( LoadedFile file, List messages, String newGenre ) 37 | { 38 | if( String.IsNullOrWhiteSpace( newGenre ) ) newGenre = null; 39 | 40 | String oldGenre = file.Tag.JoinedGenres; 41 | 42 | if( newGenre == null && String.IsNullOrWhiteSpace( oldGenre ) ) return; // NOOP 43 | 44 | if( String.Equals( oldGenre, newGenre, StringComparison.Ordinal ) ) return; // NOOP, includes null 45 | 46 | file.Tag.Genres = ( newGenre == null ) ? null : new String[] { newGenre }; 47 | 48 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Genres), oldGenre, newGenre ); 49 | file.IsModified = true; 50 | 51 | file.RecoveryTag.Genre = oldGenre; 52 | } 53 | 54 | public static void SetTrackNumber( LoadedFile file, List messages, UInt32? newTrack ) 55 | { 56 | UInt32 oldTrack = file.Tag.Track; 57 | 58 | if( oldTrack == newTrack ) return; // NOOP 59 | 60 | UInt32 newTrackValue = newTrack ?? 0; 61 | 62 | file.Tag.Track = newTrackValue; // it's kinda messy to clear tags using TagLib, it's a poorly-designed API (I noticed!): https://stackoverflow.com/questions/21343938/delete-all-pictures-of-an-id3-tag-with-taglib-sharp 63 | 64 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Track), oldTrack.ToString(), newTrack.ToString() ); 65 | file.IsModified = true; 66 | 67 | file.RecoveryTag.TrackNumber = (Int32)newTrackValue; 68 | } 69 | 70 | public static void SetTitle( LoadedFile file, List messages, String newTitle ) 71 | { 72 | String oldTitle = file.Tag.Title; 73 | 74 | if( String.Equals( oldTitle, newTitle, StringComparison.Ordinal ) ) return; // NOOP, includes null 75 | 76 | file.Tag.Title = newTitle; 77 | 78 | messages.AddFileChange( file.FileInfo.FullName, nameof(TagLib.Tag.Title), oldTitle, newTitle ); 79 | file.IsModified = true; 80 | 81 | file.RecoveryTag.Title = oldTitle; 82 | } 83 | 84 | public static void Revert( LoadedFile file, List messages ) 85 | { 86 | RecoveryTag rt = file.RecoveryTag; 87 | 88 | if( rt.Artist != null ) file.Tag.Performers = new String[] { rt.Artist }; 89 | if( rt.Album != null ) file.Tag.Album = rt.Album; 90 | if( rt.TrackNumber != null ) file.Tag.Track = (UInt32)rt.TrackNumber; 91 | if( rt.Genre != null ) file.Tag.Genres = new String[] { rt.Genre }; 92 | if( rt.Title != null ) file.Tag.Title = rt.Title; 93 | 94 | file.RecoveryTag.Clear(); 95 | file.IsModified = true; 96 | 97 | messages.AddInfoFile( file.FileInfo.FullName, "File recovered." ); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /TeslaTags/Services/TeslaTagService/TeslaTagFolderProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | using TagLib; 7 | 8 | namespace TeslaTags 9 | { 10 | public static class TeslaTagFolderProcessor 11 | { 12 | public static (FolderType folderType, Int32 modifiedCountProposed, Int32 modifiedCountActual, Int32 totalCount) Process(String directoryPath, HashSet fileExtensionsToLoad, Boolean readOnly, Boolean undo, GenreRules genreRules, List messages) 13 | { 14 | List files = LoadFiles( directoryPath, fileExtensionsToLoad, messages ); 15 | try 16 | { 17 | FolderType folderType; 18 | if( undo ) 19 | { 20 | Boolean anyReverted = Retagger.RetagForUndo( files, messages ); 21 | 22 | if( anyReverted ) folderType = FolderType.Reverted; 23 | else folderType = FolderType.Skipped; 24 | } 25 | else 26 | { 27 | folderType = DetermineFolderType( directoryPath, files, messages ); 28 | switch( folderType ) 29 | { 30 | case FolderType.ArtistAlbum: 31 | case FolderType.ArtistAlbumNoTrackNumbers: 32 | Retagger.RetagForArtistAlbum( files, messages, trackNumbersExpected: ( folderType != FolderType.ArtistAlbumNoTrackNumbers ) ); 33 | break; 34 | case FolderType.ArtistAlbumWithGuestArtists: 35 | Retagger.RetagForArtistAlbumWithGuestArtists( files, messages ); 36 | break; 37 | case FolderType.ArtistAssorted: 38 | Retagger.RetagForArtistAssortedFiles( files, messages ); 39 | break; 40 | case FolderType.AssortedFiles: 41 | Retagger.RetagForAssortedFiles( files, messages ); 42 | break; 43 | case FolderType.CompilationAlbum: 44 | Retagger.RetagForCompilationAlbum( files, messages ); 45 | break; 46 | case FolderType.Empty: 47 | case FolderType.UnableToDetermine: 48 | case FolderType.Skipped: 49 | default: 50 | break; 51 | } 52 | 53 | Retagger.RetagForGenre( folderType, files, genreRules, messages ); 54 | } 55 | 56 | Int32 proposedModifiedCount = 0; 57 | Int32 actualModifiedCount = 0; 58 | foreach( LoadedFile file in files ) 59 | { 60 | if( file.IsModified ) proposedModifiedCount++; 61 | 62 | if( !readOnly && file.IsModified ) 63 | { 64 | try 65 | { 66 | file.Save( messages ); 67 | } 68 | catch(Exception ex) 69 | { 70 | messages.Add( new Message( MessageSeverity.Error, directoryPath, file.FileInfo.FullName, "Could not save file: " + ex.Message ) ); 71 | } 72 | 73 | actualModifiedCount++; 74 | } 75 | } 76 | 77 | return (folderType, proposedModifiedCount, actualModifiedCount, files.Count); 78 | } 79 | finally 80 | { 81 | foreach( LoadedFile file in files ) 82 | { 83 | file.Dispose(); 84 | } 85 | } 86 | } 87 | 88 | public static List LoadFiles( String directoryPath, HashSet fileExtensionsToLoad, List messages ) 89 | { 90 | DirectoryInfo di = new DirectoryInfo( directoryPath ); 91 | 92 | List loadedFiles = new List(); 93 | foreach( FileInfo fi in di.GetFiles().Where( fi => fileExtensionsToLoad.Contains( fi.Extension ) ) ) 94 | { 95 | if( LoadedFile.TryLoadFromFile( fi, messages, out LoadedFile loadedFile ) ) 96 | { 97 | loadedFiles.Add( loadedFile ); 98 | } 99 | } 100 | 101 | return loadedFiles; 102 | } 103 | 104 | /// Creates a comma-separated lexicographically-ordered list of distinct values in using to get each value. Nonempty values are wrapped in double-quotes. Empty values are displayed as "null". 105 | private static String GetList( List files, Func selector ) 106 | { 107 | List values = files 108 | .Select( ft => selector( ft.Tag ) ) 109 | .Distinct() 110 | .OrderBy( str => str ) 111 | .Select( str => String.IsNullOrWhiteSpace( str ) ? "null" : ( '"' + str + '"' ) ) 112 | .ToList(); 113 | 114 | return String.Join( ",", values ); 115 | } 116 | 117 | private static FolderType DetermineFolderType( String directoryPath, List files, List messages ) 118 | { 119 | if( files.Count == 0 ) return FolderType.Empty; 120 | 121 | Boolean allAlbumArtistsAreVariousArtists = files.All( f => f.Tag.FirstAlbumArtist.EqualsCI( Values.VariousArtistsConst ) ); 122 | 123 | String firstAlbumArtist = files.First().Tag.GetAlbumArtist(); 124 | Boolean allSameAlbumArtist = !String.IsNullOrWhiteSpace( firstAlbumArtist ) && files.All( f => f.Tag.GetAlbumArtist().EqualsCI( firstAlbumArtist ) ); 125 | 126 | String firstArtist = files.First().Tag.GetPerformers(); 127 | Boolean allSameArtist = !String.IsNullOrWhiteSpace( firstArtist ) && files.All( ft => ft.Tag.GetPerformers().EqualsCI( firstArtist ) ); 128 | 129 | String firstAlbum = files.First().Tag.Album; 130 | Boolean allSameAlbum = !String.IsNullOrWhiteSpace( firstAlbum ) && files.All( ft => ft.Tag.Album.EqualsCI( firstAlbum ) ); 131 | Boolean allNoAlbum = files.All( ft => String.IsNullOrWhiteSpace( ft.Tag.Album ) ); 132 | 133 | if( allAlbumArtistsAreVariousArtists ) 134 | { 135 | if( allNoAlbum ) return FolderType.AssortedFiles; 136 | 137 | if( allSameAlbum ) return FolderType.CompilationAlbum; 138 | 139 | String differentAlbums = GetList( files, ft => ft.Album ); 140 | String messageText = "Unexpected folder type: All tracks have AlbumArtist = \"Various Artists\", but they have different Album values: " + differentAlbums; 141 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, messageText ) ); 142 | return FolderType.UnableToDetermine; 143 | } 144 | else 145 | { 146 | if( allSameArtist ) 147 | { 148 | if( allNoAlbum ) 149 | { 150 | return FolderType.ArtistAssorted; 151 | } 152 | else if( allSameAlbum ) 153 | { 154 | // If none of the files have track-numbers in their filenames and they're all lacking track number tags, then it's something like a video-game soundtrack dump where track-numbers don't apply: 155 | Boolean anyHaveTrackNumberTags = files.Any( ft => ft.Tag.Track > 0 ); 156 | 157 | var folderDiscTrackInfo = DiscAndTrackNumberHelper.GetDiscTrackNumberForAllFiles( directoryPath ); 158 | 159 | Boolean anyHaveTrackNumberNames = false; 160 | if( folderDiscTrackInfo.hasBest ) 161 | { 162 | anyHaveTrackNumberNames = folderDiscTrackInfo.files.Values.Any( t => t.track != null ); 163 | } 164 | 165 | if( anyHaveTrackNumberTags || anyHaveTrackNumberNames ) 166 | { 167 | return FolderType.ArtistAlbum; 168 | } 169 | else 170 | { 171 | return FolderType.ArtistAlbumNoTrackNumbers; // I note that the "No track numbers" thing is orthogonal to a folder's type, e.g. an `ArtistAlbumWithGuestArtists` could have no track numbers too! TODO: Fix this! Consider making `FolderType` a flags enum and `NoTrackNumbers` as a high-bit flag? 172 | } 173 | } 174 | else 175 | { 176 | String differentAlbums = GetList( files, ft => ft.Album ); 177 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, "Folder has same artist, but has multiple albums (" + differentAlbums + "). " ) ); 178 | return FolderType.UnableToDetermine; 179 | } 180 | } 181 | else if( allSameAlbumArtist ) 182 | { 183 | if( allNoAlbum ) 184 | { 185 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, "Folder has no albums" ) ); 186 | return FolderType.UnableToDetermine; 187 | } 188 | else if( allSameAlbum ) 189 | { 190 | return FolderType.ArtistAlbumWithGuestArtists; 191 | } 192 | else 193 | { 194 | String differentArtists = GetList( files, ft => ft.GetPerformers() ); 195 | String differentAlbums = GetList( files, ft => ft.Album ); 196 | 197 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, "Folder has same album-artist, but multiple artists (" + differentArtists + ") or multiple albums (" + differentAlbums + "). " ) ); 198 | return FolderType.UnableToDetermine; 199 | } 200 | } 201 | else 202 | { 203 | // Different Artists and/or Album Artists and/or Albums, i.e. a mess. Inform the user to tidy it up. 204 | String differentArtists = GetList( files, ft => ft.FirstPerformer ); 205 | String differentAlbums = GetList( files, ft => ft.Album ); 206 | String differentAlbumArtists = GetList( files, ft => ft.FirstAlbumArtist ); 207 | 208 | String messageText = "Folder has multiple artists (" + differentArtists + "), albums (" + differentAlbums + ") or album-artists (" + differentAlbumArtists + ")."; 209 | messages.Add( new Message( MessageSeverity.Error, directoryPath, directoryPath, messageText ) ); 210 | return FolderType.UnableToDetermine; 211 | } 212 | } 213 | } 214 | } 215 | 216 | 217 | } 218 | -------------------------------------------------------------------------------- /TeslaTags/TagExperiments.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | 4 | using TagLib; 5 | 6 | namespace TeslaTags 7 | { 8 | public static class TagExperiments 9 | { 10 | private const Char MongolianVowelSeparator = '\u180E'; 11 | private const Char ZeroWidthSpace = '\u200B'; 12 | private const Char ZeroWidthNoBreakSpace = '\uFEFF'; 13 | 14 | private static readonly Char[] _base3Digits = new[] { MongolianVowelSeparator, ZeroWidthSpace, ZeroWidthNoBreakSpace }; 15 | 16 | private static void PrependInvisibleCharactersUsingBase3(TagLib.Mpeg.AudioFile audioFile, Int32 sortOrder, Int32 maxSortOrder) 17 | { 18 | TagLib.Tag id3v1 = audioFile.GetTag( TagTypes.Id3v1 ); // TODO: What happens if ID3v1 is removed? 19 | TagLib.Tag id3v2 = audioFile.GetTag( TagTypes.Id3v2 ); 20 | 21 | // Naive approach: Prepend with MongolianVowelSeparator repeated `sortOrder` times (i.e. base-1) 22 | // Smarter approach: Treat the 3 zero-width unicode characters as a base-3 radix number system, and encode the sortOrder as that). 23 | 24 | Int32 minStringLength = LengthOfBase3String( maxSortOrder ); 25 | String prefix = ToBase3( sortOrder, minStringLength ); 26 | 27 | String trimmedTitle = Trim( id3v2.Title ); 28 | String prefixedTitle = prefix + trimmedTitle; 29 | 30 | id3v2.Title = prefixedTitle; 31 | } 32 | 33 | private static void PrependInvisibleCharactersUsingBase1(TagLib.Mpeg.AudioFile audioFile, Int32 sortOrder, Int32 maxSortOrder) 34 | { 35 | TagLib.Tag id3v1 = audioFile.GetTag( TagTypes.Id3v1 ); 36 | TagLib.Tag id3v2 = audioFile.GetTag( TagTypes.Id3v2 ); 37 | 38 | // Naive approach: Prepend with MongolianVowelSeparator repeated `sortOrder` times (i.e. base-1) 39 | 40 | String prefix = String.Empty.PadLeft( sortOrder, MongolianVowelSeparator ); 41 | 42 | String trimmedTitle = Trim( id3v2.Title ); 43 | String prefixedTitle = prefix + trimmedTitle; 44 | 45 | id3v2.Title = prefixedTitle; 46 | } 47 | 48 | private static void PrependTrackNumber(TagLib.Mpeg.AudioFile audioFile, Int32 sortOrder, Int32 maxSortOrder) 49 | { 50 | Int32 maxLength = LengthOfBase10String( (UInt32)maxSortOrder ); 51 | String prefix = sortOrder.ToString( CultureInfo.InvariantCulture ).PadLeft( maxLength, '0' ); 52 | 53 | TagLib.Tag id3v1 = audioFile.GetTag( TagTypes.Id3v1 ); 54 | TagLib.Tag id3v2 = audioFile.GetTag( TagTypes.Id3v2 ); 55 | 56 | String trimmedTitle = Trim( id3v2.Title ); 57 | String prefixedTitle = prefix + " - " + trimmedTitle; 58 | id3v2.Title = prefixedTitle; 59 | } 60 | 61 | private static Int32 LengthOfBase3String(Int32 value) 62 | { 63 | Int32 count = 0; 64 | Int32 workingValue = value; 65 | do 66 | { 67 | workingValue = workingValue / 3; 68 | count++; 69 | } 70 | while( workingValue > 0 ); 71 | 72 | return count; 73 | } 74 | 75 | private static Int32 LengthOfBase10String(UInt32 value) 76 | { 77 | if( value < 10 ) return 1; 78 | if( value < 100 ) return 2; 79 | if( value < 1000 ) return 3; 80 | if( value < 10000 ) return 4; 81 | throw new ArgumentOutOfRangeException( nameof(value), value, "Value must be in the range 0-9999" ); 82 | } 83 | 84 | private static String ToBase3(Int32 value, Int32 minStringLength) 85 | { 86 | Int32 workingValue = value; 87 | 88 | Char[] output = new Char[ Math.Max( minStringLength, 10 ) ]; 89 | for( Int32 i = 0; i < output.Length; i++ ) output[i] = _base3Digits[0]; 90 | 91 | Int32 o = output.GetUpperBound(0); 92 | 93 | do 94 | { 95 | Int32 digit = workingValue % 3; 96 | workingValue = workingValue / 3; 97 | 98 | output[o--] = _base3Digits[digit]; 99 | } 100 | while( o >= 0 && workingValue > 0 ); 101 | 102 | //Int32 startIndex = o + 1; 103 | //Int32 length = output.Length - startIndex; 104 | 105 | Int32 startIndex = output.Length - minStringLength; 106 | Int32 length = minStringLength; 107 | 108 | String base3String = new String( output, startIndex, length ); 109 | return base3String; 110 | } 111 | 112 | private static String Trim(String value) 113 | { 114 | // Fun-fact: String.Trim() uses Char.IsWhiteSpace() to determine what to remove. 115 | // However Char.IsWhiteSpace() returns false for the 3 characters we're using because they're considered "Format" characters instead of spacing. 116 | // So this reimplementation checks both. 117 | 118 | // optimization: return if it doesn't need trimming: 119 | 120 | Int32 trimFromStart = 0; 121 | for( Int32 i = 0; i < value.Length; i++ ) 122 | { 123 | if( IsWhiteSpace( value[i] ) ) trimFromStart++; 124 | else break; 125 | } 126 | 127 | if( trimFromStart == value.Length ) return String.Empty; 128 | 129 | Int32 trimFromEnd = 0; 130 | for( Int32 i = value.Length - 1; i >= 0; i-- ) 131 | { 132 | if( IsWhiteSpace( value[i] ) ) trimFromEnd++; 133 | else break; 134 | } 135 | 136 | if( trimFromStart == 0 && trimFromEnd == 0 ) return value; 137 | 138 | Int32 substringLength = ( value.Length - trimFromStart ) - trimFromEnd; 139 | 140 | String substring = value.Substring( trimFromStart, substringLength ); 141 | return substring; 142 | } 143 | 144 | private static Boolean IsWhiteSpace(Char value) 145 | { 146 | switch( value ) 147 | { 148 | case MongolianVowelSeparator: 149 | case ZeroWidthSpace: 150 | case ZeroWidthNoBreakSpace: 151 | return true; 152 | default: 153 | return Char.IsWhiteSpace( value ); 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /TeslaTags/TeslaTags.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {831FE5D4-250F-4F55-9934-5072F2B7CBEE} 8 | Exe 9 | TeslaTags 10 | TeslaTags 11 | v4.7.1 12 | 512 13 | true 14 | 15 | 16 | AnyCPU 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 7.3 25 | AllRules.ruleset 26 | false 27 | 28 | 29 | AnyCPU 30 | pdbonly 31 | true 32 | bin\Release\ 33 | TRACE 34 | prompt 35 | 4 36 | 37 | 38 | 39 | ..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll 40 | 41 | 42 | ..\packages\taglib.2.1.0.0\lib\policy.2.0.taglib-sharp.dll 43 | 44 | 45 | 46 | 47 | 48 | 49 | ..\packages\taglib.2.1.0.0\lib\taglib-sharp.dll 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /TeslaTags/Values.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TeslaTags 4 | { 5 | public static class Values 6 | { 7 | internal const String VariousArtistsConst = "Various Artists"; 8 | internal const String NoAlbumConst = "No Album"; 9 | 10 | public static String VariousArtists => VariousArtistsConst; 11 | public static String NoAlbum => NoAlbumConst; 12 | 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /TeslaTags/packages.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages-other/ShellFileDialogs/ShellFileDialogs.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/packages-other/ShellFileDialogs/ShellFileDialogs.dll -------------------------------------------------------------------------------- /packages-other/ShellFileDialogs/ShellFileDialogs.pdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daiplusplus/TeslaTags/f1a835f25ef7ad7907743a5132ca217f5a62f138/packages-other/ShellFileDialogs/ShellFileDialogs.pdb --------------------------------------------------------------------------------