Click here to Skip to main content
15,880,796 members
Articles / Web Development / HTML

M3U-Copy a tool for playlists

Rate me:
Please Sign up or sign in to vote.
4.67/5 (4 votes)
7 May 2015CPOL13 min read 27.8K   1.1K   9   1
M3U-Copy copies the entries of a playlist in the M3U format to a target directory or rewrites the playlist

Program was tested under Windows 7 32 bit, Windows 8.1 64 bit and Windows 10 64bit. It uses the .Net Framework in version 4.5 and C# 5.  Version 4.5 are required because i used its INotifyDataErrorInfo interface and Task library. I used Visual Studio 2015 Community. WPF is used for the GUI.

Version 1.1 from the 05/07/2015 adds help and an Installshield Limited installer.

Version 1.2 from the 09/05/2015 fixed three minor bugs, used Visual Studio 2015 Community. Switched  the installer to Wix 3.9.2. For compile you have to reinstall the nuget packages (they are listed under "Installed" in the packet manager).

Introduction

Mainwindow:

Mainwindow

When you rewrite the playlist you can choose "Relative Paths" to convert the paths in the "Playlist Output"  file to paths which are relative to the location of the "Playlist Output file". I tested M3U-Copy with Winamp v5.666, VLC media player v2.06 and Windows Media Player from Windows 8.1. Windows Media Player doesn't accept relative paths. Files in the M3U-Format have the extension ".m3u" and "m3u8". The difference ist that files with the "m3u8" extension are expected to use UTF8-Unicode encoding for their content. Windows Media Player accepts only files with the ".m3u" extension. M3U-Copy can convert the path of the entries to local shares, when possible. The option for that is "Use UNC Paths". The option "Use Administrative Shares" uses the shares with the name <drive letter>$ which are present for every drive, but can only be accessed with administrative rights. Neither from the tested players support Network pathes for the media files. But i wanted to have that in, and i am planning to write a own media player. When there are UNC names in the input playlist, i leave them unmodified.When copying the directory structure gets recreated and the letter of the drive is included in the hierarchy. When you choose "Remove Drives", the drive letter is stripped from the directory structure. If you choose "Flatten" no directories are generated and all media files are created in the "Target Path". The option "Hardlink" does not copy files when the source and target are on the same ntfs formatted volume. This are no Shortcuts and behave exactly as if they were the source files. If you can hardlink, nearly no time is required to make the copy.

To create hardlinks i use the CreateHardLink method located in the static class OS which is implemented via PInvoke:

C#
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode)]
public static extern bool CreateHardLink(
string lpFileName,
string lpExistingFileName,
IntPtr lpSecurityAttributes
);

Here comes a sample for the contents of a M3U file:

#EXTM3U
#EXTINF:29,Coldplay - Glass Of Water
C:\Projects\M3U-Copy-Tests\Source\Public\mp3\Coldplay\01 Glass Of Water-30sec.mp3
#EXTINF:31,Coldplay - Clocks
C:\Projects\M3U-Copy-Tests\Source\Public\mp3\Coldplay\03 Clocks-30sec.mp3

The first lines contains the constant string "EXTM3U" to descibe the format of the file. For each media file are two lines in the playlist file. The first line begins with "EXTINF" followed by the play length in seconds and the name to display in the playlist. The second line contains the path to the media file.

Classes

The functionality of M3U-Copy is quite limited, but 1700 lines auf code were needed to implement it.

The UML class diagram, no fields, methods and properties are listed:

Class diagram

VMBase

Is the base class for my classes which are TwoWay bound to the user interface, which are M3Us, M3U and Configuration. VM stands for ViewModel.

  • It implements the following interfaces:
  • INotifyPropertyChanged: .net 4.5 interface to TwoWay bind normal properties to the UI.
  • INotifyDataErrorInfo .net 4.5 interface to handle and display errors, explained in the error handling section.
  • INotifyDataErrorInfoAdd interface , own extension to handle and display errors, explained in the error handling section.

M3U

Contains the data of a single media file entry and its state which has the class States and file info. Implements no logic.

States

Hold the state of a playlist entry, for example Copied or Errors.

Configuration

Contains the options, for example for "Playlist" and "Relative Paths". There exists a class named ConfigurationTest which although implements IConfiguration and is used for the unit-test. Visualstudio failed, when i tried to add this class to the diagram.

M3Us

Holds the main logic, including reading playlists, transforming entries and copying files. It contains a collection from M3u objects. which represent the entries in the playlist. Holds references to a IConfig and SharesBase instance.

Shares

Contains the methods to deal with shares. SharesTest is used when running unit-tests.

OS

Contains operating system near code, like CreateHardLink and IsAdmin.

App

Contains code to allow objects in the M3U_Copy.Dpmain assembly to access the Settings in the application. Deals with dynamic resources.

Startup

In the Application the following methods are fired on startup:

C#
 private void Application_Startup(object sender, StartupEventArgs e) {
        Resources_en = Resources.MergedDictionaries[0];
        SwitchLanguage(M3U_Copy.UI.Properties.Settings.Default.Language);
 }

public void SwitchLanguage(string culture) {
    if (culture == "de") {
        Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
        if (Resources_de == null) {
            string path = OS.AssemblyDirectory + "\\" + "Resources.de.xaml";
            Resources_de = new ResourceDictionary();
            Resources_de.Source = new Uri(path);
        }
        Resources.MergedDictionaries.Add(Resources_de);
        Resources.MergedDictionaries.RemoveAt(0);
    } else {
        Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
        Resources.MergedDictionaries.Add(Resources_en);
        Resources.MergedDictionaries.RemoveAt(0);
    }
 }

The resources for the UI are set in the App.xaml for the language "en-US", they are contained in a ResourceDictionary named "Resources_en". When the culture "de-DE" is selected, the resources are read from the file "Resources.de.xaml".

In "MainWindow.xaml.cs" we call these two routines on startup:

C#
 public MainWindow() {
    log4net.Config.XmlConfigurator.Configure();
    IConfiguration conf = new Configuration();
    ViewModel = new M3Us(conf, (IView)this);
    conf.ViewModel = ViewModel;
    ViewModel.Shares = new Shares();
    ViewModel.Loading = true;
    ((IApp)Application.Current).ViewModel = ViewModel;
    DataContext = ViewModel;
    InitializeComponent();
}

private void Window_Loaded(object sender, RoutedEventArgs e) {
    try {
        log.Info(M3U_Copy.Domain.Properties.Resources.Started);
        ViewModel.Conf.Validate();
        ViewModel.Loading = false;
        M3Us.Instance.ReadPlaylistAsync();
        M3Us.Instance.ApplicationStarting = false;
    } catch (Exception ex) {
        log.Error(M3U_Copy.Domain.Properties.Resources.SetupFailure, ex);
        M3Us.Instance.AddError(M3U_Copy.Domain.Properties.Resources.SetupFailure, ex.ToString());
        M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
    }
}

The M3Us singleton is created and the associations to the objects implementing ShareBase and IConfiguration are set. M3Us is set as DataContext, so that you can bind in the "MainWindow.xaml" file to the M3Us instance. Loading is set to true, to prevent the Commands to fire when the MainWindow is initialized. ApplicationStarting only blocks the execution of the setter for MP3UFilename during startup.

General Layout and Databinding

I didn't use the cider Editor from Visual Studio and directly entered the XAML.But cider displays the XAML correctly.  The main form is located in MainWindow.xmal.

The root container is a Grid. Its third row in which the datagrid is places has a "*" as width, what means that the datagrid dynamically gets all of the remaining space left after layouting the other elements. This has the effect, that when you change the heigth of the windows the DataGrid resizes too.The grid contains serval panels, including the panel types StackPanel and WrapPanel. The DataGrid and the TextBoxes at top resize their width with the width of the window is changed because their Grid column have a width of "*".

The DataContextis set to our M3Us instance, so we can use Binding expressions to synchronize our data.

The XAML code for our DataGrid:

XML
<DataGrid Grid.Row="0" ItemsSource="{Binding ValidatesOnExceptions=true, 
	NotifyOnValidationError=true,Path=Entries}" AutoGenerateColumns="false" 
	SelectionMode="Extended" SelectionUnit="FullRow" Style="{StaticResource DataGridReadOnly}"
	ScrollViewer.CanContentScroll="True" 
	ScrollViewer.VerticalScrollBarVisibility="Auto" 
	ScrollViewer.HorizontalScrollBarVisibility="Auto"
	IsSynchronizedWithCurrentItem="True" CanUserAddRows="False" x:Name="dgMain" 
	VerticalAlignment="Top">
	<DataGrid.Columns>
		<DataGridComboBoxColumn Header="{DynamicResource State}" ItemsSource="{Binding 
			Source={StaticResource States}}" >
			<DataGridComboBoxColumn.CellStyle>
				<Style TargetType="DataGridCell">
					<Setter Property="Background" Value="{Binding State, Mode=OneWay, 
						Converter={StaticResource StateToBackgroundConverter}}" />
				</Style>
			</DataGridComboBoxColumn.CellStyle>
						</DataGridComboBoxColumn>
					<DataGridTextColumn Header="{StaticResource NameSR}" Binding="{Binding Name, Mode=OneWay}" />
					<DataGridTextColumn Header="{StaticResource SizeKBSR}" 
        		Binding="{Binding SizeInKB, Mode=OneWay, ConverterCulture={x:Static 
			gl:CultureInfo.CurrentCulture}, 
				StringFormat=\{0:0\,0\}}" CellStyle="{StaticResource CellRightAlign}" />
			<DataGridCheckBoxColumn Header="{StaticResource HardlinkSR}" Binding="{Binding 
			Hardlinked, Mode=OneWay}" IsThreeState="True"
				ElementStyle="{StaticResource ErrorStyle}"/>
			<DataGridTextColumn Header="{StaticResource MP3UFilenameSR}" Binding="{Binding 
			MP3UFilename, Mode=OneWay, 
				ValidatesOnNotifyDataErrors=True}" ElementStyle="{StaticResource ErrorStyle}"/>
		</DataGrid.Columns>
</DataGrid>

DataGrid> is a ItemsControl which doesn' bind to a single property, it binds to a collection of objects: in this case to Path=Entries, which holds the list of our media files. The IsSynchronizedWithCurrentItem="True" is needed when you want to bind single items controls like TextBox to Entries, they will bind to the object which is selected in the DataGrid. For the State column we use a Converter to show the states as background color:

C#
public class StateToBackgroundConverter : IValueConverter {
       public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
           System.Diagnostics.Debug.Assert(targetType == typeof(System.Windows.Media.Brush));
           States state = (States)value;
           System.Windows.Media.Brush brush = System.Windows.Media.Brushes.White;

           switch (state) {
               case States.Unprocessed: brush = System.Windows.Media.Brushes.White; break;
               case States.Copied: brush = System.Windows.Media.Brushes.Green; break;
               case States.Errors: brush = System.Windows.Media.Brushes.Red; break;
               case States.Processing: brush = System.Windows.Media.Brushes.Yellow; break;
               case States.Warnings: brush = System.Windows.Media.Brushes.Orange; break;
               case States.Duplicate: brush = System.Windows.Media.Brushes.Brown; break;
               case States.CheckingForDuplicate: brush = System.Windows.Media.Brushes.LightYellow; break;
           }
           return brush;
       }

       public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
           throw new NotImplementedException("The method or operation is not implemented.");
       }
}

Example for binding the TextBox named "tbSourcePathDetail":

XML
<TextBox Grid.Column="1" Name="tbSourcePathDetail" 
	HorizontalAlignment="Stretch" 
		VerticalAlignment="Top" TextWrapping="Wrap" 
		Text="{Binding Path=Entries/MP3UFilename, Mode=OneWay, ValidatesOnNotifyDataErrors=True}" 
		Style="{StaticResource ErrorStyleDisabled}" Margin="0 ,0,4,0" IsReadOnly="true">
</TextBox>

Commands

I used Commands to bind the actions of the user interface controls. The commands are located in the "Command.cs" file, located in the M3U_Copy.UI project. They are implemented as singletons and implement the ICommand interface. There are other ways to bind commands. The TextBoxes are not bound to Commands, instead they have a databinding to the properties of the Configuration or M3uclass. The setter in the Configuration class validate the input and triggers calls to ReadPlaylistAsync.

The commands must be added to the CommandBindings of the window:

XML
<Window.CommandBindings>
       <CommandBinding Command="{x:Static ui:SetLanguageCommand.Instance}"/>
       <CommandBinding Command="{x:Static ui:FlattenCommand.Instance}"/>
       ...

In the controls you use the Command and CommandParameter attributes to bind the control to a Command. The CommandParameter below evaluates to the Checkbox named "chkFlatten". A sample from the file "MainWindow.xmal:

XML
<CheckBox x:Name="chkFlatten" 
        Content="{DynamicResource Flatten}" Margin="4,4,4,4" 
        IsChecked="{Binding Path=Conf.Flatten}"
        Command="{x:Static ui:FlattenCommand.Instance}" 
        CommandParameter="{Binding RelativeSource={RelativeSource Self}}" 
        x:FieldModifier="internal"></CheckBox>

Sample implementation of a Command:

C#
public class FlattenCommand : ICommand {
	private static readonly log4net.ILog log = 
	log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
	private static ICommand instance;
	public static ICommand Instance {
		get {
			if (instance == null)
			instance = new FlattenCommand();
			return instance;}
		set { instance = value;}
	}

	public event EventHandler CanExecuteChanged {
		add { CommandManager.RequerySuggested += value; }
				remove { CommandManager.RequerySuggested -= value; }
	}
		public void Execute(object parameter) {
			if (parameter == null) return;
			CheckBox cbe = (CheckBox)parameter;
			if (cbe.IsChecked == true) {
				IConfiguration conf = M3Us.Instance.Conf;
				bool wasLoading = M3Us.Instance.Loading;
				M3Us.Instance.Loading = true;
				conf.OnlyRewritePlaylist = false;
				conf.RemoveDrives = false;
				M3Us.Instance.Loading = wasLoading;
				}
			if (!M3Us.Instance.Loading) {
				log.Debug("FlattenCommand is calling ReadPlaylistAsync.");
				M3Us.Instance.ReadPlaylistAsync();
			} else {
				log.Debug("FlattenCommand: someone is already loading the playlist. Do not call ReadPlaylistAsync.");
				}
			}
	public bool CanExecute(object sender) {
			if (((IApp)Application.Current).ViewModel.IsBusy) return false;
			return true;
		}
}

M3Us.Instance and ((IApp)Application.Current).ViewModel both refer to our main singleton of the class M3Us. The CanExecute method is automatically called by WPF and disables the control, when it returns false. We check the IsBusy property of our M3Us singleton. IsBusy is true, when we are copying the playlist. During the copy process all Checkboxes and TextBoxes are disabled, with exception of the "stop" button. In the Execute method we disable the Checkboxes which are contra directional to our own setting. For example if we select the chkFlatten CheckBox we don't create any folders in the target directory and it makes no sense, to strip the drive folder from the target dir (RemoveDrives). For security we use the Loading property to prohib that the CheckBox we change fires it Execute method, which will trigger additional calls of ReadPlaylistAsync. If we change a CheckBox or the content of a TextBox, the playlist is immediately loaded in the background.

Configuration Class

The values in the Configuration class are saved as Settings in the M3U_Copy.UI project with "User" as scope:

Settings

Because i needed to access the Settings from the M3U_Copy.UI and M3U_Copy.Domain project, i have to add an interface named IApp and implemented it in the App:

C#
public void SaveSettings() {
            M3U_Copy.UI.Properties.Settings.Default.Save();
        }
        public string  GetStringSetting(string key) {
            return (string) M3U_Copy.UI.Properties.Settings.Default[key];
        }
        public void SetStringSetting(string key, string value) {
            M3U_Copy.UI.Properties.Settings.Default[key]=value;
        }
        public bool GetBoolSetting(string key) {
            return (bool)M3U_Copy.UI.Properties.Settings.Default[key];
        }
        public void SetBoolSetting(string key, bool value) {
            M3U_Copy.UI.Properties.Settings.Default[key] = value;
        }
        public bool? GetBoolNSetting(string key) {
            return (bool?)M3U_Copy.UI.Properties.Settings.Default[key];
        }
        public void SetBoolNSetting(string key, bool? value) {
            M3U_Copy.UI.Properties.Settings.Default[key] = value;
        }
        ...
        private void Application_Exit(object sender, ExitEventArgs e) {
            SaveSettings(); 
        }
        public M3Us ViewModel { get; set; }

The code should be clear. The Application_Exit event is wired up in "App.xaml":

XML
<Application x:Class="M3U_Copy.UI.App"
        ...
        Startup="Application_Startup"
        Exit="Application_Exit" >

The disabling of the Textboxes when IsBusy is handled by a Style. XAML definition for the TextBox which shows the name of the input playlist:

XML
<TextBox Grid.Column="2" Name="tbPlaylistName" HorizontalAlignment="Stretch" TextWrapping="Wrap"
        Text="{Binding Path=Conf.MP3UFilename}" VerticalAlignment="Top" Margin="4,4,4,4"
        Style="{StaticResource ErrorStyleAutoDisabled}">

Definition of the used style:

XML
 <Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyleAutoDisabled" 
	BasedOn="{StaticResource ErrorStyle}">
	<Style.Triggers>
			<DataTrigger Binding="{Binding Path=IsBusy}" Value="true">
					<Setter Property="TextBox.Foreground"
				Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
					<Setter Property="TextBox.IsReadOnly" Value="true"/>
			</DataTrigger>
		</Style.Triggers>
</Style> 

We can bind to IsBusy because our M3Us singleton is set as DataContext for the "MainWindow". We use a DynamicResource for the Background to immediatley adapt to windows theme changes during runtime.

A sample for a property in our Configuration class for the setting of the input playlist:

C#
public string MP3UFilename {
            get {
                return iApp.GetStringSetting("MP3UFilename");
            }
            set {
                if (MP3UFilename == value && !ViewModel.ApplicationStarting) return;
                MP3UFilenameChanged = true;
                iApp.SetStringSetting("MP3UFilename", value);
                this.RemoveError("MP3UFilename");
                if (string.IsNullOrEmpty(value)) {
                    log.Error(M3U_Copy.Domain.Properties.Resources.SpecifyInputFile);
                    AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.SpecifyInputFile);
                }
                else if (!File.Exists(value)) {
                    log.Error(M3U_Copy.Domain.Properties.Resources.InputFileDoesNotExist);
                    AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.InputFileDoesNotExist + ": " + value);
                }
                bool wasLoading = M3Us.Instance.Loading;
                ViewModel.Loading = true;
                OnPropertyChanged(new PropertyChangedEventArgs("MP3UFilename"));
                M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Conf"));
                M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Entries"));
                MP3UFilenameOut = MP3UFilenameOut;
                M3Us.Instance.Loading = wasLoading;
                if (!ViewModel.Loading) {
                    log.Debug("MP3UFilename is calling ReadPlaylistAsync.");
                    ViewModel.ReadPlaylistAsync();
                } else {
                    log.Debug("MP3UFilename: someone is already loading the playlist. Do not call ReadPlaylistAsync.");
                }
                ViewModel.Loading = wasLoading;
            }
        }

M3Us inherits from VMBase which implements the INotifyPropertyChanged interface. This means every time you change a property participating in databinding you have to fire the self-defined event PropertyChanged by calling OnPropertyChanged and pass in the name of the changed property packaged in a PropertyChangedEventArgs, as shown above. TwoWay databinding in WPF require normally the use of a DependencyProperty to bind to controls. When implementing INotifyPropertyChanged TwoWay binding works with normal properties. Here is the complete definition from VMBaseclass:

C#
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e) {
    if (PropertyChanged != null) {
        PropertyChanged(this, e);
    }
}

The properties for the Checkboxes in the Configuration class are all simple, the actions are done in the corresponding Commands. A sample:

C#
public bool OnlyRewritePlaylist {
       get {
           return iApp.GetBoolSetting("OnlyRewritePlaylist");
       }
       set {
           iApp.SetBoolSetting("OnlyRewritePlaylist", value);
       }
   }

Drag and Drop

In Windows 8.1 initially Drag and Drop doesn't work. This problem hits not only M3U-Copy. I fixed it following the instructions in Drag Drop Not Working in Windows 8. You have to reboot after the described procedure.

The buttons for the selection of the input playlist and target path accept Drag and Drop beside clicks.

XAML definition for the M3U input playlist:

XML
<Button Name="SourcePathButton" Content="{DynamicResource SourcePathButton}"
   Click="SourcePathButton_Click" Margin="4,4,4,4"
                       AllowDrop="True" PreviewDrop=""
   PreviewDragEnter="SourcePathButton_PreviewDragEnter"
           PreviewDragOver="SourcePathButton_PreviewDragOver"></Button>

Code for handling drag and drop:

    private void SourcePathButton_PreviewDrop(object sender, DragEventArgs e) {
        object text = e.Data.GetData(DataFormats.FileDrop);
        tbPlaylistName.Text = ((string[])text)[0];
        e.Handled = true;
    }

    private void SourcePathButton_PreviewDragEnter(object sender, DragEventArgs e) {
        if (e.Data.GetDataPresent("FileDrop"))
            e.Effects = DragDropEffects.Copy;
        else
            e.Effects = DragDropEffects.None;
        e.Handled = true;
    }

    private void SourcePathButton_PreviewDragOver(object sender, DragEventArgs e) {
        if (e.Data.GetDataPresent("FileDrop"))
            e.Effects = DragDropEffects.Copy;
        else
            e.Effects = DragDropEffects.None;
        e.Handled = true;
    }  

Threading

The read of the playlist and the coping occur in background threads.

When the configuration  is changed, the playlist is immediately reloaded.

For Example the Execute method from the FlattenCommand:

C#
public void Execute(object parameter){
    if (parameter == null)
        return;
    CheckBox cbe = (CheckBox)parameter;
    if (cbe.IsChecked == true) {
        IConfiguration conf = M3Us.Instance.Conf;
        bool wasLoading = M3Us.Instance.Loading;
        M3Us.Instance.Loading = true;
        conf.OnlyRewritePlaylist = false;
        conf.RemoveDrives = false;
        M3Us.Instance.Loading = wasLoading;
    }
    if (!M3Us.Instance.Loading)
        M3Us.Instance.ReadPlaylistAsync();
} 

ReadPlaylistAsync reads the playlist in the background, CopyAsync copies the media items in the background. I didn't use the recommended await, because i wanted full control over the threads. But next time i will try await. CopyAsync was simpler to implement than ReadPlaylistAsync, because the GUI ensures that when the copy thread runs, no other background threads can be started. Code for copying:

C#
 public bool CopyAsync() {
    try {
       Status = "StatusCopying";
       OnPropertyChanged(new PropertyChangedEventArgs("IsBusy"));
       SizeCopiedInBytes = 0L;
       ClearErrors();
        ReadPlaylistAsync(false);
        CopySynchronizationContext = SynchronizationContext.Current;
        CopyCancellationTokenSource = new CancellationTokenSource();
        CopyTask = new Task<bool>(() => { return Copy(CopyCancellationTokenSource.Token); });
        CopyTask.ContinueWith(t => AfterCopy());
        CopyTask.Start();
        return true;
    }
    catch (Exception ex) {
        log.Error("CopyAsync", ex);
        AddError("CopyAsync", ex.ToString());
        OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
    }
    return false;
}

public void AfterCopy() {
    CopySynchronizationContext.Send((@object) => { OnPropertyChanged(new PropertyChangedEventArgs("IsBusy")); }, null);
}

public bool CopyAsyncStop() {
    try {
        if (CopyTask != null) {
            if (IsBusy) {
                CopyCancellationTokenSource.Cancel(true);
            }
            log.Info("Copy canceld.");
        }
    }
    catch (Exception ex) {
        log.Error("CopyAsyncStop", ex);
        AddError("CopyAsyncStop", ex.ToString());
        OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
    }
    return true;
}

 public class M3Us : VMBase {
    ...
    public bool IsBusy {
        get {
            bool ret;
            if (CopyTask != null && CopyTask.Status == TaskStatus.Running)
                ret = true;
            else ret = false;
            return ret;
        }
    }
    ...
    public Task<bool> ReadPlaylistTask;
    public SynchronizationContext ReadPlaylistSynchronizationContext;
    public CancellationTokenSource ReadPlaylistCancellationTokenSource;
    public Task<bool> CopyTask;
    public SynchronizationContext CopySynchronizationContext;
    public CancellationTokenSource CopyCancellationTokenSource;
    ...
public bool Copy(CancellationToken ct) {
            bool ret = true;
            M3U m3U = null;
            IApp ia = (IApp)Application.Current;
            try {
                ReadPlaylistWait(ReadPlaylistCancellationTokenSource.Token);
                if (!Directory.Exists(Conf.TargetPath)) {
                    Directory.CreateDirectory(Conf.TargetPath);
                }
                if (ct.IsCancellationRequested) return false;
                if (!Conf.OnlyRewritePlaylist) {
                    for (int i = 0; i < Entries.Count; i++) {
                        try {
                            if (ct.IsCancellationRequested) return false;
                            m3U = Entries[i];
        ...
</bool></bool></bool>

In our M3Us singleton, we remember the CopyTask and its CancellationTokenSource. This is also done for the ReadPlaylistTask. With CopyTask.ContinueWith(t => AfterCopy());we specify that the method AfterCopy is run, when the CopyTask is finished.

In AfterCopy, we only set IsBusy to false. Because IsBusy is in a object which is bound to the UI, we have to use CopySynchronizationContext.Send, which executes the given Lambda expression in the UI thread. Send executes synchronus, the alternative Post executes asynchronous. To end our CopyTask we issue CopyCancellationTokenSource.Cancel(true);. This doesn't end the task on its own. In the running thread, we have to check the state of the CancellationToken. Example out of Copy(CancellationToken ct) :

C#
...
if (ct.IsCancellationRequested) return false;
        if (!Conf.OnlyRewritePlaylist) {
            for (int i = 0; i < Entries.Count; i++) {
                try {
                    if (ct.IsCancellationRequested) return false;
...
 using (FileStream fsin = File.OpenRead(m3U.MP3UFilename)) {
 using (FileStream fsout = File.OpenWrite(m3U.TargetPath)) {
    byte[] buffer = new byte[blocksize];
    int read;
    CopySynchronizationContext.Send((@object) => { m3U.SizeCopiedInBytes = 0L; }, null);
    log.Info(string.Format(M3U_Copy.Domain.Properties.Resources.Copying, m3U.Name));
    while ((read = fsin.Read(buffer, 0, blocksize)) > 0) {
        fsout.Write(buffer, 0, read);
        CopySynchronizationContext.Send((@object) => {
            m3U.SizeCopiedInBytes += read;
            SizeCopiedInBytes += read;
        }, null);
        if (ct.IsCancellationRequested) {
            return false;
        }
    }
    CopySynchronizationContext.Send((@object) => { m3U.State = States.Copied; }, null);
}

We check the CancellationToken multiple times with ct.IsCancellationRequested and return from our threaded method when a cancel is requested. The important check is done after every block copied to the media file. Note when we change the m3U.SizeCopiedInBytes we have to do it on the UI thread using CopySynchronizationContext.Send, because this property is bound to a ProgressBar.

Code regarding the ReadPlaylistTask which is similar to the code for the CopyTask:

C#
public bool ReadPlaylistAsync(bool wait = false) {
           try {

               lock (this) {
                   M3Us.Instance.Conf.ClearErrors();
                   M3Us.Instance.Conf.Validate();
                   M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Conf"));

                   if (M3Us.Instance.Conf.HasErrors) {
                       log.Debug("Configuration has errors. Don't reload playlist.");
                       return false;
                   }
                   M3U m3U = view.SelectedGridEntry();
                   if (m3U != null) SelectedEntry = m3U;
                   Status = M3U_Copy.Domain.Properties.Resources.StatusReadingPlaylist;
                   if (ReadPlaylistCancellationTokenSource == null) {
                       log.Debug("ReadPlaylistAsync: CancellationTokenSource is null skiping ReadPlaylistAsyncStop");
                   } else {
                       log.Debug("ReadPlaylistAsync: calling ReadPlaylistAsyncStop");
                       ReadPlaylistAsyncStop(ReadPlaylistCancellationTokenSource.Token);
                   }
                   ReadPlaylistSynchronizationContext = SynchronizationContext.Current;
                   ReadPlaylistCancellationTokenSource = new CancellationTokenSource();

                   CancellationToken token = ReadPlaylistCancellationTokenSource.Token;
                   ReadPlaylistTask = new Task<bool>(() => ReadPlaylist(token), token);
                   ReadPlaylistTask.ContinueWith(t => {
                       log.Debug(string.Format("ReadPlaylistAsync in ContinueWith for thread with ID {0} and status {1}.",
                           t.Id, t.Status.ToString()));
                       ReadPlaylistSynchronizationContext.Send((@object) => Status = "", null);
                       ReadPlaylistSynchronizationContext.Send((@object) => Conf.MP3UFilenameChanged = false, null);
                   });

                   ReadPlaylistSynchronizationContext.Send((@object) => Status = "StatusReadingPlaylist", null);
                   if (wait) {
                       log.Debug(string.Format("ReadPlaylistAsync: Running ReadPlaylistTask synchronously id is {0}.", ReadPlaylistTask.Id));
                       ReadPlaylistTask.RunSynchronously();
                   } else {
                       log.Debug(string.Format("ReadPlaylistAsync: Running ReadPlaylistTask id is {0}.", ReadPlaylistTask.Id));
                       ReadPlaylistTask.Start();
                   }
                   return true;
               }
           }
           catch (Exception ex) {
               log.Error("ReadPlaylistAsync", ex);
               AddError("ReadPlaylistAsync", ex.ToString());
               OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
           }
           return false;
       }
public bool ReadPlaylistAsyncStop(CancellationToken token) {
       try {
           if (ReadPlaylistTask != null) {
               int threadID = ReadPlaylistTask.Id;
               log.Debug(string.Format("ReadPlaylistAsyncStop current ReadPlaylistTask has id {0} and status {1}.", threadID, ReadPlaylistTask.Status.ToString()));
               if (ReadPlaylistTask.Status == TaskStatus.WaitingToRun) {
                   while (ReadPlaylistTask.Status == TaskStatus.WaitingToRun) {
                       log.Debug(string.Format("ReadPlaylistAsyncStop there is a not started ReadPlaylistTask with id {0}. Sleeping.", threadID));
                       Thread.Sleep(500);
                   }
                   log.Debug(string.Format("ReadPlaylistAsyncStop there is a not started ReadPlaylistTask with id {0} is running.", threadID));
               }
               if (ReadPlaylistTask.Status == TaskStatus.Running
                   || ReadPlaylistTask.Status == TaskStatus.WaitingForActivation
                   || ReadPlaylistTask.Status == TaskStatus.WaitingForChildrenToComplete) {
                   log.Debug(string.Format("ReadPlaylistAsyncStop there is a running ReadPlaylistTask with id {0}. Canceling and waiting.", threadID));
                   if (token == null) {
                       log.Debug("ReadPlaylistAsyncStop Error: Trying to cancel task with a null CancellationTokenSource");
                   } else {
                       this.ReadPlaylistCancellationTokenSource.Cancel();
                       ReadPlaylistTask.Wait(token);
                       log.Debug(string.Format("ReadPlaylistAsyncStop ReadPlaylistTask with id {0} returned control.", threadID));
                   }
               } else {
                   log.Debug(string.Format("ReadPlaylistAsyncStop thee ReadPlaylistTask with id {0} is not running or waiting. Nothing to cancel.", threadID));
               }
           } else {
               log.Debug("ReadPlaylistAsyncStop ReadPlaylistTask is null. Nothing todo.");
           }
       }
       catch (Exception ex) {
           log.Error("ReadPlaylistAsyncStop", ex);
           AddError("ReadPlaylistAsyncStop", ex.ToString());
           OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
       }
       return true;
   }

   public bool ReadPlaylistWait(CancellationToken token) {
           bool ret = true;
           try {
               if (ReadPlaylistTask == null) {
                   return true;
               }else {
                   int threadID = ReadPlaylistTask.Id;
                   log.Debug(string.Format("ReadPlaylistWait current ReadPlaylistTask has id {0} and status {1}.", threadID, ReadPlaylistTask.Status.ToString()));
                   if (ReadPlaylistTask.Status == TaskStatus.Canceled
                       || ReadPlaylistTask.Status == TaskStatus.Faulted
                       || ReadPlaylistTask.Status == TaskStatus.RanToCompletion) {
                       return true;
                   } else {
                       ReadPlaylistTask.Wait(token);
                       return true;
                   }
               }
           }
           catch (Exception ex) {
               log.Error("ReadPlaylistWait", ex);
               CopySynchronizationContext.Send((@object) => {
                   AddError("ReadPlaylistWait", ex.ToString());
                   OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
               }, null);
               ret = false;
           }
           return ret;
   }
   </bool>

In ReadPlaylistAsync is a lock(this), to guarantee that only one thread in a time runs ReadPlaylistAsync. In ReadPlaylistAsyncStop is the line ReadPlaylistTask.Wait(token);. Which blocks the current thread until the previous ReadPlaylistTask is finished. I first used ReadPlaylistTask.Wait(); which waited infinitely.

Error handling

When there are errors for a control WPF displays them with an red border:

Error 1

>M3U-Copy additional displays a red triangle associated with a ToolTip:

Error 2

XML
Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyle">
	<Setter Property="VerticalAlignment" Value="Center" />
	<Setter Property="Margin" Value="0,0,0,0" />
	<Setter Property="Validation.ErrorTemplate">
		<Setter.Value>
			<ControlTemplate>
				<Grid>
					<Border BorderBrush="Red" BorderThickness="1">
						<AdornedElementPlaceholder/>
					</Border>
					<Polygon Points="40,20 40,0 0,0"
						Stroke="Black"
						StrokeThickness="1"
						Fill="Red"
						HorizontalAlignment="Right"
						VerticalAlignment="Top">
						<Polygon.ToolTip>
							<ItemsControl ItemsSource="{Binding}">
								<ItemsControl.ItemTemplate>
									<DataTemplate>
										<TextBlock Text="{Binding ErrorContent}" Foreground="Red"/>
									</DataTemplate>
								</ItemsControl.ItemTemplate>
							</ItemsControl>
						</Polygon.ToolTip>
					</Polygon>
				</Grid>
			</ControlTemplate>
		</Setter.Value>
	</Setter>
</Style>

<Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyleAutoDisabled" BasedOn="{StaticResource ErrorStyle}">
	<Style.Triggers>
		<DataTrigger Binding="{Binding Path=IsBusy}" Value="true">
			<Setter Property="TextBox.Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
			<Setter Property="TextBox.IsReadOnly" Value="true"/>
		</DataTrigger>
	</Style.Triggers>
</Style>
...
TextBox Grid.Row="1" Name="tbTargetPath" Grid.Column="2" HorizontalAlignment="Stretch" TextWrapping="Wrap" 
		Text="{Binding Path=Conf.TargetPath}" VerticalAlignment="Top" Margin="4,4,4,4" 
	Style="{StaticResource ErrorStyleAutoDisabled}" 
		IsEnabled="{Binding ElementName=chkOnlyRewritePlaylist, Path=IsChecked, 
	Converter={StaticResource NegateBool}}"/>

The extended error display comes from the style named "ErrorStyle". This style is set for the TextBox named "tbTargetPath" by assigning the style named "ErrorStyleAutoDisabled" which inherits from "ErrorStyle" because it has a BasedOn="{StaticResource ErrorStyle}". The "ErrorStyle" style was copied from the internet.

XML
IsEnabled="{Binding ElementName=chkOnlyRewritePlaylist, Path=IsChecked,
   Converter={StaticResource NegateBool}}"

The Statement above enables the TextBox "tbTargetPath" when the option "Only Rewrite Playlist" is false. The converter is simple:

C#
public class NegateBool : System.Windows.Data.IValueConverter {
        public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
            bool  ret = (bool)value;
            return !ret;
        }

        public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
            throw new NotImplementedException();
        }
}

The framework 4.5 interface INotifyPropertyChanged is implemented in the VMBase class:

C#
#region INotifyDataErrorInfo
        public void OnErrorsChanged(string propertyName) {
            if (ErrorsChanged != null)
                ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
        }
        public Dictionary<string, string="">> errors = new Dictionary<string, string="">>();

        public event EventHandler<dataerrorschangedeventargs> ErrorsChanged;

        public System.Collections.IEnumerable GetErrors(string propertyName) {
            if (!string.IsNullOrEmpty(propertyName)) {
                if (errors.ContainsKey(propertyName) && (errors[propertyName] != null) && errors[propertyName].Count > 0)
                    return errors[propertyName].ToList();
                else
                    return null;
            } else
                return errors.SelectMany(err => err.Value.ToList());
        }

       public bool HasErrors {
            get {
                return errors.Any(propErrors => propErrors.Value.Count > 0);
            }
        }
#endregion
</dataerrorschangedeventargs></string,></string,>

Note that for every property multiple errors can be set, If you pass a null or empty string in the method GetErrors all errors set in the class are returned. After you change an error entry you must call OnErrorsChanged(propertyName); and WPF will display the error, if the property/object is bound to a control. I have added some methods for error managment, from the VMBaseclass:

C#
#region INotifyDataErrorInfoAdd
public IEnumerable<string> Errors {
    get {
        List<string> entries = new List<string>();
            foreach (KeyValuePair<string, string="">> entry in errors) {
                string prefix = Properties.Resources.ResourceManager.GetString(entry.Key);
                if (prefix == null) prefix = entry.Key;
                foreach (string message in entry.Value) {
                    entries.Add(prefix + ": " + message);
                }
            }
        return entries;
    }
}  
public void AddError(string propertyName, string message) {
    if (string.IsNullOrEmpty(propertyName)) {
        return;
    }
    if (errors.ContainsKey(propertyName) && (errors[propertyName] != null)) {
        errors[propertyName].Add(message);
    } else {
        List<string> li = new List<string>();
        li.Add(message);
        errors.Add(propertyName, li);
    }
    OnErrorsChanged(propertyName);
}
public void ClearErrors() {
    errors.Clear();
    OnErrorsChanged(null);
}
public void RemoveError(string propertyName) {
    if (errors.ContainsKey(propertyName))
        errors.Remove(propertyName);
    OnErrorsChanged(propertyName);
}
#endregion
</string></string></string,></string></string></string>

The Errors property is used to bind to error summaries at the bottom auf the "MainWindow":

XML
<ScrollViewer Grid.Row="5" MaxHeight="100">
		<StackPanel >
			<ItemsControl x:Name="ConfigurationErrors"
				ItemsSource="{Binding Path=Conf.Errors, Mode=OneWay, 
			ValidatesOnNotifyDataErrors=True}"
					Foreground="DarkRed" ItemTemplate="{StaticResource WrapDataTemplate}">
			</ItemsControl >
			<ItemsControl x:Name="M3UErrors"
				ItemsSource="{Binding Path=Entries/Errors, Mode=OneWay, 
			ValidatesOnNotifyDataErrors=True}"
				Foreground="Red" ItemTemplate="{StaticResource WrapDataTemplate}">
			</ItemsControl >
			<ItemsControl x:Name="M3UsErrors"
				ItemsSource="{Binding Path=Errors, Mod			ValidatesOnNotifyDataErrors=True}"
				Foreground="DarkGoldenrod" ItemTemplate="{StaticResource WrapDataTemplate}" >
			</ItemsControl >
		</StackPanel>
</ScrollViewer>

Sample for a localized error message:

C#
m3u.AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.TranslateSourcePathEmptySource);

Logging

M3U-Copy uses log4net in the version 1.2.13 for logging. Its added as nugetpackage.

The logfile is rotated, named M3U_Copy.log and sits in the AppData\Local\M3U_Copy directory, the messages are logged to the console too.

The available log leves ordered by severity are FATAL, ERROR, WARN, INFO, Debug. The default value is DEBUG. With level DEBUG i log trace messages which are not localized. To change the log level edit in the App Config, the two configuration pairs:

XML
 <levelMin value="DEBUG"/>
<levelMax value="FATAL"/>

I nice tool to observe log files is the free program Logexpert.

The Shares class

It loads the shares with WMI:

C#
 public class Shares : SharesBase {
    public override void Load() {
        diskShares = new Dictionary<string, string="">();
        adminDiskShares = new Dictionary<string, string="">();
        string path = string.Format(@"\\{0}\root\cimv2", Environment.MachineName);
        string query = "select Name, Path, Type from win32_share";
        ManagementObjectSearcher worker = new ManagementObjectSearcher(path, query);
        foreach (ManagementObject share in worker.Get()) {
            if (share["Type"].ToString() == "0") { //  0 = DiskDrive, 2147483648 = Disk Drive Admin 
                diskShares.Add(share["Name"].ToString(), share["Path"].ToString());
            }
            else if (uint.Parse(share["Type"].ToString()) == 2147483648)
                adminDiskShares.Add(share["Name"].ToString(), share["Path"].ToString());
        }
    }
}     
</string,></string,>

A sample function of the SharesBase class which converts a UNC-name to a path:

C#
public string  UncToPath(string fileName) {

    return UncToPath(fileName, DiskShares);
}

public  string UncToPathAdmin(string fileName) {

    return UncToPath(fileName, AdminDiskShares);
}
C#
protected  string UncToPath(string uncName, Dictionary<string, string=""> shares) {
    StringBuilder sb = new StringBuilder();
    if (!uncName.StartsWith(@"\\")) return null;
    int index = uncName.IndexOf(Path.DirectorySeparatorChar, 2);
    if (index < 0) return null;
    string serverName = uncName.Substring(2, index - 2);
    if (!IsLocalShare(uncName)) return null;
    int index2 = uncName.IndexOf(Path.DirectorySeparatorChar, index + 1);
    string shareName = uncName.Substring(index + 1, index2 - index - 1);
    KeyValuePair<string, string=""> entry = (from share in shares
                                            where string.Compare(shareName, share.Key, true) == 0
                                            orderby share.Value.Length descending
                                            select share).FirstOrDefault();
    if (string.IsNullOrEmpty(entry.Key)) return null;
    sb.Append(entry.Value);
    if (!entry.Value.EndsWith(Path.DirectorySeparatorChar.ToString())) sb.Append(Path.DirectorySeparatorChar);
    sb.Append(uncName.Substring(index2 + 1));
    return sb.ToString();
}
</string,></string,>

Testing

I use Nuinit in the version 2.6.3 for unit-tests. They are added as nuget packages. You have to add NUnit and NUnit.Runners. The nuint file is named "Test-M3U-Copy.nunit" and is located in the root directory from the source. Lists of tests:

Tests

A sample test:

C#
 [TestFixture]
public class M3Us_TranslateSourcePath2TargetPath
{
    private static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
    private M3Us m3us;
    private M3U m3u;
    IConfiguration conf;

    [SetUp]
    public void TestFixtureSetup()
    {
        m3u = new M3U();
        m3u.Name = "AC DC Thunderstruck";
        m3u.MP3UFilename = @"M:\Archive public\YouTube\AC DC Thunderstruck.mp4";
        conf = new ConfigurationTest();
        conf.MP3UFilename = @"C:\temp\Video.m3u8";
        conf.TargetPath = @"M:\Playlists";
        conf.MP3UFilenameOut = @"c:\temp\playlist\gothic.m3u8";
        m3us = new M3Us(conf, null);
        m3us.Shares = new SharesTest();
    }

    [Test]
    [Category("Nondestructive")]
    public void RelativePaths_false()
    {
        m3us.Conf.RelativePaths = false;
        m3us.TranslateSourcePath2TargetPath(m3u);
        StringAssert.AreEqualIgnoringCase(@"M:\Playlists\AC DC Thunderstruck.mp4", m3u.TargetPlaylistName);
        StringAssert.AreEqualIgnoringCase(@"M:\Playlists\AC DC Thunderstruck.mp4", m3u.TargetPath);
    }

    [Test]
    [Category("Nondestructive")]
    public void Flatten_false__RelativePaths_false()
    {
        m3us.Conf.Flatten = false;
        m3us.Conf.RelativePaths = false;
        m3us.TranslateSourcePath2TargetPath(m3u);
        StringAssert.AreEqualIgnoringCase(@"M:\Playlists\M\Archive public\YouTube\AC DC Thunderstruck.mp4", m3u.TargetPlaylistName);
        StringAssert.AreEqualIgnoringCase(@"M:\Playlists\M\Archive public\YouTube\AC DC Thunderstruck.mp4", m3u.TargetPath);
    }
    ...

The SetUp attribute is used inside a TestFixture to provide a common set of functions that are performed just before each test method is called. In it we instantiate a M3us singleton and set its conf association to the mocked class ConfigurationTest which implements the interface IConfiguration. The Shares property is set to an instance of our mocked SharesTest class.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer Winfried Wille Softwareentwicklung
Germany Germany
I am a .Net programmer working since 25 years as software developer and system engineer. I live in Hamburg, Germany.
My application and blog are here http://w2ww.de .
Currently i work as freelancer.My hourly price is 40€. I gladly accept fixed price projects.

Comments and Discussions

 
SuggestionParsing Windows Player Playlist (*.wpl) request Pin
hooger201714-May-16 6:37
hooger201714-May-16 6:37 
Hi Winfried, great tool ! Koodos.

Can you add a feature to parser Windows Player Playlist (*.wpl) file. If you don't have time I can contribute. Smile | :)

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.