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:
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:
[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:
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:
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:
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 DataContext
is set to our M3Us
instance, so we can use Binding
expressions to synchronize our data.
The XAML code for our DataGrid
:
<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:
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":
<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 theConfiguration
or M3u
class. 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:
<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:
<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:
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:
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
:
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":
<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:
<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:
<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:
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 VMBase
class:
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:
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:
<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
:
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:
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>
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)
:
...
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
:
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:
>M3U-Copy additional displays a red triangle associated with a ToolTip
:
Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyle">
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.
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:
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:
#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>
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 VMBase
class:
#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>
The Errors
property is used to bind to error summaries at the bottom auf the "MainWindow":
<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:
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:
<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:
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") { 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,>
A sample function of the SharesBase
class which converts a UNC-name to a path:
public string UncToPath(string fileName) {
return UncToPath(fileName, DiskShares);
}
public string UncToPathAdmin(string fileName) {
return UncToPath(fileName, AdminDiskShares);
}
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,>
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:
A sample test:
[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.