65.9K
CodeProject 正在变化。 阅读更多。
Home

M3U-Copy - 播放列表工具

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.67/5 (4投票s)

2015年3月13日

CPOL

13分钟阅读

viewsIcon

28863

downloadIcon

1214

M3U-Copy 将 M3U 格式播放列表中的条目复制到目标目录或重写播放列表。

程序已在 Windows 7 32 位、Windows 8.1 64 位和 Windows 10 64 位下测试。它使用 .Net Framework 4.5 版本和 C# 5.0。需要 4.5 版本,因为我使用了它的 INotifyDataErrorInfo 接口和 Task 库。我使用了 Visual Studio 2015 Community。WPF 用于 GUI。

2015年7月5日的 1.1 版本增加了帮助和 Installshield Limited 安装程序。

2015年5月9日的 1.2 版本修复了三个小错误,使用了 Visual Studio 2015 Community。将安装程序切换到了 Wix 3.9.2。编译时需要重新安装 nuget 包(它们在包管理器中列为“已安装”)。

引言

主窗口

Mainwindow

重写播放列表时,可以选择“相对路径”,将“播放列表输出”文件中的路径转换为相对于“播放列表输出”文件位置的路径。我用 Winamp v5.666、VLC media player v2.06 和 Windows 8.1 的 Windows Media Player 测试了 M3U-Copy。Windows Media Player 不接受相对路径。M3U 格式的文件扩展名为“.m3u”和“m3u8”。区别在于“m3u8”扩展名的文件内容应使用 UTF8-Unicode 编码。Windows Media Player 只接受“.m3u”扩展名的文件。M3U-Copy 在可能的情况下可以将条目路径转换为本地共享。该选项是“使用 UNC 路径”。“使用管理员共享”选项使用每个驱动器都有的、但只能通过管理员权限访问的、名为 $ 的共享。测试的播放器都不支持媒体文件的网络路径。但我想添加这个功能,并计划编写一个自己的媒体播放器。当输入播放列表中有 UNC 名称时,我将不作修改。复制时,目录结构会被重新创建,并且驱动器盘符会包含在层级中。如果选择“移除驱动器”,则驱动器盘符将从目录结构中剥离。如果选择“扁平化”,则不会生成任何目录,所有媒体文件都将在“目标路径”中创建。“硬链接”选项在源和目标位于同一个 ntfs 格式卷上时,不会复制文件。这些不是快捷方式,行为与源文件完全相同。如果可以硬链接,复制几乎不需要时间。

为了创建硬链接,我使用了位于静态类 OS 中的 CreateHardLink 方法,该方法通过 PInvoke 实现。

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

M3U 文件内容示例:

#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

前几行包含常量字符串“EXTM3U”来描述文件格式。每个媒体文件在播放列表文件中都有两行。第一行以“EXTINF”开头,后跟秒数播放时长和播放列表中显示的名称。第二行包含媒体文件的路径。

M3U-Copy 的功能相当有限,但实现了 1700 行代码。

UML 类图,未列出字段、方法和属性。

Class diagram

VMBase

是我与用户界面进行TwoWay 绑定的类的基类,这些类是 M3UsM3UConfiguration。VM 代表 ViewModel。

  • 它实现了以下接口:
  • INotifyPropertyChanged:.net 4.5 接口,用于将普通属性TwoWay 绑定到 UI。
  • INotifyDataErrorInfo:.net 4.5 接口,用于处理和显示错误,详见错误处理部分。
  • INotifyDataErrorInfoAdd 接口:我们自己的扩展,用于处理和显示错误,详见错误处理部分。

M3U

包含单个媒体文件条目及其 state(具有 States 类)和文件信息。不实现任何逻辑。

状态

保存播放列表条目的状态,例如 CopiedErrors

配置

包含选项,例如用于“播放列表”和“相对路径”。存在一个名为 ConfigurationTest 的类,它也实现了 IConfiguration,并用于单元测试。当我尝试将此类添加到图中时,Visualstudio 失败了。

M3Us

包含主要逻辑,包括读取播放列表、转换条目和复制文件。它包含一个 M3u 对象集合,代表播放列表中的条目。保存对 IConfigSharesBase 实例的引用。

Shares

包含处理共享的方法。SharesTest 在运行单元测试时使用。

操作系统

包含接近操作系统级别的代码,例如 CreateHardLinkIsAdmin

App

包含允许 M3U_Copy.Dpmain 程序集中的对象访问应用程序 Settings 的代码。处理动态资源。

启动

Application 中,以下方法在启动时触发:

     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);
        }
     }

UI 的资源在 App.xaml 中为“en-US”语言设置,它们包含在名为“Resources_en”的 ResourceDictionary 中。当选择“de-DE”区域性时,资源将从文件“Resources.de.xaml”中读取。

在“MainWindow.xaml.cs”中,我们在启动时调用这两个例程:

 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"));
    }
}

创建 M3Us 单例,并设置与实现 ShareBase 和 IConfiguration 的对象的关联。将 M3Us 设置为 DataContext,以便您可以在“MainWindow.xaml”文件中绑定到 M3Us 实例。加载设置为 true,以防止 CommandsMainWindow 初始化时触发。ApplicationStarting 仅阻止在启动期间设置 MP3UFilename 的 setter 的执行。

通用布局和数据绑定

我没有使用 Visual Studio 的 cider 编辑器,而是直接输入了 XAML。但 cider 能正确显示 XAML。主窗体位于 MainWindow.xmal。

根容器是 Grid。它的第三行放置了 datagrid,其宽度为“*”,这意味着 datagrid 在布局完其他元素后动态获得所有剩余空间。效果是,当您更改窗口高度时,DataGrid 也会随之调整。Grid 包含多个面板,包括 StackPanel WrapPanel 类型的面板。当窗口宽度改变时,DataGrid 和顶部的文本框的宽度会随之改变,因为它们的 Grid 列的宽度为“*”。

DataContext 设置为我们的 M3Us 实例,因此我们可以使用 Binding 表达式来同步我们的数据。

我们的 DataGrid 的 XAML 代码:

<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 是一个 ItemsControl,它不绑定到单个属性,而是绑定到一个对象集合:在本例中是 Path=Entries,它保存我们的媒体文件列表。当您想要将单个项控件(如 TextBox)绑定到 Entries 时,需要 IsSynchronizedWithCurrentItem="True",它们将绑定到 DataGrid 中选定的对象。对于 State 列,我们使用 Converter 将状态显示为背景颜色。

 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.");
        }
 }  

绑定名为“tbSourcePathDetail”的 TextBox 的示例:

<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

我使用 Commands 来绑定用户界面控件的操作。这些命令位于 M3U_Copy.UI 项目中的“Command.cs”文件中。它们实现为单例并实现 ICommand 接口。还有其他绑定命令的方法。TextBox 没有绑定到 Commands,而是与 Configuration M3u 类的属性进行数据绑定。Configuration 类中的 setter 会验证输入并触发对 ReadPlaylistAsync 的调用。

命令必须添加到窗口的 CommandBindings 中。

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

在控件中,您使用 Command CommandParameter 属性将控件绑定到 Command 。下面的 CommandParameter 求值为名为“chkFlatten”的 Checkbox 。文件“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>

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 ((IApp)Application.Current).ViewModel 都指向我们 M3Us 类的主要单例。CanExecute 方法由 WPF 自动调用,并在返回 false 时禁用控件。我们检查我们 M3Us 单例的 IsBusy 属性。IsBusy 在我们复制播放列表时为 true。在复制过程中,除“停止”按钮外,所有 Checkbox 和 TextBox 都被禁用。在 Execute 方法中,我们禁用与我们自己的设置相矛盾的 Checkbox。例如,如果我们选择 chkFlatten CheckBox,我们不会在目标目录中创建任何文件夹,并且剥离目标目录(RemoveDrives)的驱动器文件夹没有意义。出于安全考虑,我们使用 Loading 属性来阻止我们更改的 CheckBox 触发其 Execute 方法,这将触发对 ReadPlaylistAsync 的额外调用。如果我们更改 CheckBox 或 TextBox 的内容,播放列表会立即在后台加载。

配置类

Configuration 类中的值以“User”范围保存在 M3U_Copy.UI 项目的 Settings 中。

Settings

因为我需要从 M3U_Copy.UI 和 M3U_Copy.Domain 项目访问 Settings,所以我必须添加一个名为 IApp 的接口并在 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; }
    

代码应该是清晰的。Application_Exit 事件在“App.xaml”中连接。

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

TextBox 的禁用(当 IsBusy 时)由 Style 处理。用于显示输入播放列表名称的 TextBox 的 XAML 定义:

    <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}">
    

使用的 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> 

我们可以绑定到 IsBusy,因为我们的 M3Us 单例被设置为“MainWindow”的 DataContext。我们为 Background 使用 DynamicResource,以便在运行时立即适应 Windows 主题更改。

我们 Configuration 类中用于设置输入播放列表的属性的示例:

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 继承自 VMBase,它实现了 INotifyPropertyChanged 接口。这意味着每次更改参与数据绑定的属性时,您都必须通过调用 OnPropertyChanged 并传入包装在 PropertyChangedEventArgs 中的更改属性的名称来触发自定义事件 PropertyChanged,如上所示。WPF 中的 TwoWay 数据绑定通常需要使用 DependencyProperty 来绑定到控件。实现 INotifyPropertyChanged 时,TwoWay 绑定可与普通属性一起使用。这是 VMBase 类完整的定义:

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

Configuration 类中 Checkbox 的属性都很简单,操作在相应的 Commands 中完成。示例:

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

拖放

在 Windows 8.1 中,最初 Drag and Drop 不起作用。这个问题不仅影响 M3U-Copy。我按照 Drag Drop Not Working in Windows 8 中的说明解决了它。在此过程之后,您需要重新启动。

用于选择输入播放列表和目标路径的按钮除了点击外,还支持拖放。

M3U 输入播放列表的 XAML 定义:

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

处理拖放的代码:

    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;
    }  

多线程

播放列表的读取和复制发生在后台线程中。

当配置更改时,播放列表会立即重新加载。

例如,FlattenCommandExecute 方法:

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 在后台读取播放列表,CopyAsync 在后台复制媒体项。我没有使用推荐的 await,因为我想完全控制线程。但下次我会尝试 awaitCopyAsync ReadPlaylistAsync 更容易实现,因为 GUI 确保当复制线程运行时,没有其他后台线程可以启动。复制代码:

 public bool CopyAsync() {
    try {
       Status = "StatusCopying";
       OnPropertyChanged(new PropertyChangedEventArgs("IsBusy"));
       SizeCopiedInBytes = 0L;
       ClearErrors();
        ReadPlaylistAsync(false);
        CopySynchronizationContext = SynchronizationContext.Current;
        CopyCancellationTokenSource = new CancellationTokenSource();
        CopyTask = new Task(() => { 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 ReadPlaylistTask;
    public SynchronizationContext ReadPlaylistSynchronizationContext;
    public CancellationTokenSource ReadPlaylistCancellationTokenSource;
    public Task 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];
        ...

在我们的 M3Us 单例中,我们记住 CopyTask 及其 CancellationTokenSourceReadPlaylistTask 也这样做。通过 CopyTask.ContinueWith(t => AfterCopy());,我们指定在 CopyTask 完成后运行 AfterCopy 方法。

AfterCopy 中,我们只将 IsBusy 设置为 false。因为 IsBusy 位于与 UI 绑定的对象中,所以我们必须使用 CopySynchronizationContext.Send,它在 UI 线程中执行给定的 Lambda 表达式。Send 同步执行,替代的 Post 异步执行。为了结束我们的 CopyTask,我们发出 CopyCancellationTokenSource.Cancel(true);。这本身并不能结束任务。在运行的线程中,我们必须检查 CancellationToken 的状态。例如,从 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);
    }    

我们多次检查 CancellationToken(使用 ct.IsCancellationRequested),并在请求取消时从我们的线程方法返回。重要的检查是在复制到媒体文件的每个块之后完成的。请注意,当我们更改 m3U.SizeCopiedInBytes 时,我们必须在 UI 线程上使用 CopySynchronizationContext.Send 来完成,因为此属性绑定到 ProgressBar

关于 ReadPlaylistTask 的代码,与 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(() => 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;
    }
    

ReadPlaylistAsync 中有一个 lock(this),以确保一次只有一个线程运行 ReadPlaylistAsync。在 ReadPlaylistAsyncStop 中,有一行 ReadPlaylistTask.Wait(token);。这会阻塞当前线程,直到之前的 ReadPlaylistTask 完成。我最初使用的是 ReadPlaylistTask.Wait();,它会无限期等待。

错误处理

当控件出现错误时,WPF 会用红色边框显示它们。

Error 1

>M3U-Copy 还显示一个与 ToolTip 关联的红色三角形。

Error 2

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}}"/>

扩展的错误显示来自名为“ErrorStyle”的 style。此样式设置为名为“tbTargetPath”的 TextBox ,通过分配名为“ErrorStyleAutoDisabled”的 style,它继承自“ErrorStyle”,因为它具有 BasedOn="{StaticResource ErrorStyle}"。“ErrorStyle”样式是从互联网复制的。

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

上面的语句启用了 TextBox “tbTargetPath”,当选项“仅重写播放列表”为 false 时。转换器很简单:

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();
        }
}

框架 4.5 接口 INotifyPropertyChangedVMBase 类中实现。

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

        public event EventHandler 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

请注意,每个属性可以设置多个错误。如果您在 GetErrors 方法中传递 null 或空字符串,则会返回类中设置的所有错误。更改错误条目后,必须调用 OnErrorsChanged(propertyName);,WPF 才会显示错误(如果属性/对象已绑定到控件)。我已从 VMBase 类添加了一些用于错误管理的函数。

#region INotifyDataErrorInfoAdd
public IEnumerable Errors {
    get {
        List entries = new List();
            foreach (KeyValuePair> 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 li = new List();
        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

Errors 属性用于绑定到“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>

本地化错误消息的示例:

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

日志记录

M3U-Copy 使用 log4net 1.2.13 版本进行日志记录。它作为 nuget 包添加。

日志文件被轮换,命名为 M3U_Copy.log,位于 AppData\Local\M3U_Copy 目录中,消息也会记录到控制台。

可用日志级别按严重性排序,包括 FATAL、ERROR、WARN、INFO、Debug。默认值为 DEBUG。使用 DEBUG 级别,我记录未本地化的跟踪消息。要更改日志级别,请在 App Config 中编辑两个配置对。

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

观察日志文件的有用工具是免费程序 Logexpert

Shares 类:

它使用 WMI 加载共享。

 public class Shares : SharesBase {
    public override void Load() {
        diskShares = new Dictionary();
        adminDiskShares = new Dictionary();
        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());
        }
    }
}     

SharesBase 类的一个示例函数,它将 UNC 名称转换为路径:

public string  UncToPath(string fileName) {

    return UncToPath(fileName, DiskShares);
}

public  string UncToPathAdmin(string fileName) {

    return UncToPath(fileName, AdminDiskShares);
}
protected  string UncToPath(string uncName, Dictionary 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 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();
}

测试

我使用 Nuinit 2.6.3 版本进行单元测试。它们作为 nuget 包添加。您需要添加 NUnit 和 NUnit.Runners。nuint 文件名为“Test-M3U-Copy.nunit”,位于源的根目录下。测试列表:

Tests

一个测试示例:

 [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);
    }
    ...

SetUp 属性用于 TestFixture 中,以提供一组通用的函数,这些函数在每个测试方法调用之前执行。在那里,我们实例化一个 M3us 单例,并将其 conf 关联设置为已模拟的 ConfigurationTest 类,该类实现了 IConfiguration 接口。Shares 属性设置为我们模拟的 SharesTest 类的实例。

© . All rights reserved.